Initial commit: enterprise digital platform with portal SSO, DigiServer, IT Assets, NetworkView, Server Monitor
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
# Stage 1: build the React/Vite app
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
# Build with /networkview/ as the base path so all asset URLs are prefixed
|
||||
ENV VITE_BASE_PATH=/networkview/
|
||||
ENV VITE_API_BASE=/networkview/api
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: serve with nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/rack-icon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NetworkView — Lab Documentation</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,18 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Serve static assets with long cache
|
||||
location ~* \.(js|css|png|jpg|svg|ico|woff2?)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# SPA fallback: return index.html for any path
|
||||
# React Router (with basename=/networkview) handles routing client-side
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
}
|
||||
Generated
+3326
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "networkview-frontend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.22.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"rehype-highlight": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.55",
|
||||
"@types/react-dom": "^18.2.19",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import HomePage from './pages/HomePage';
|
||||
import SitePage from './pages/SitePage';
|
||||
import RoomPage from './pages/RoomPage';
|
||||
import RackPage from './pages/RackPage';
|
||||
import ComponentPage from './pages/ComponentPage';
|
||||
import GraphPage from './pages/GraphPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
|
||||
// import.meta.env.BASE_URL is set by Vite to the configured `base` value.
|
||||
// Strip the trailing slash so React Router receives e.g. '/networkview'.
|
||||
const routerBase = import.meta.env.BASE_URL.replace(/\/$/, '');
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter basename={routerBase}>
|
||||
<div className="app-layout">
|
||||
<Sidebar />
|
||||
<main className="main-content">
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/sites/:siteId" element={<SitePage />} />
|
||||
<Route path="/rooms/:roomId" element={<RoomPage />} />
|
||||
<Route path="/racks/:rackId" element={<RackPage />} />
|
||||
<Route path="/components/:componentId" element={<ComponentPage />} />
|
||||
<Route path="/graph/:level/:id" element={<GraphPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import type {
|
||||
Site, Room, Rack, Component, Port, SearchResults, User, AuditEntry, DbStats
|
||||
} from './types';
|
||||
|
||||
// In production the API lives at /networkview/api (set via VITE_API_BASE at build time).
|
||||
// In development it defaults to /api, proxied by the Vite dev server.
|
||||
const BASE = import.meta.env.VITE_API_BASE ?? '/api';
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// --- Sites ---
|
||||
export const getSites = () => request<Site[]>('/sites');
|
||||
export const getSite = (id: string) => request<Site & { rooms: Room[] }>(`/sites/${id}`);
|
||||
export const createSite = (data: Partial<Site>) =>
|
||||
request<Site>('/sites', { method: 'POST', body: JSON.stringify(data) });
|
||||
export const updateSite = (id: string, data: Partial<Site>) =>
|
||||
request<Site>(`/sites/${id}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||
export const deleteSite = (id: string) =>
|
||||
request(`/sites/${id}`, { method: 'DELETE' });
|
||||
|
||||
// --- Rooms ---
|
||||
export const getRooms = (siteId: string) => request<Room[]>(`/rooms?siteId=${siteId}`);
|
||||
export const getRoom = (id: string) => request<Room>(`/rooms/${id}`);
|
||||
export const createRoom = (data: Partial<Room>) =>
|
||||
request<Room>('/rooms', { method: 'POST', body: JSON.stringify(data) });
|
||||
export const updateRoom = (id: string, data: Partial<Room>) =>
|
||||
request<Room>(`/rooms/${id}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||
export const deleteRoom = (id: string) =>
|
||||
request(`/rooms/${id}`, { method: 'DELETE' });
|
||||
|
||||
// --- Racks ---
|
||||
export const getRacks = (roomId: string) => request<Rack[]>(`/racks?roomId=${roomId}`);
|
||||
export const getRack = (id: string) => request<Rack>(`/racks/${id}`);
|
||||
export const createRack = (data: Partial<Rack>) =>
|
||||
request<Rack>('/racks', { method: 'POST', body: JSON.stringify(data) });
|
||||
export const updateRack = (id: string, data: Partial<Rack>) =>
|
||||
request<Rack>(`/racks/${id}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||
export const deleteRack = (id: string) =>
|
||||
request(`/racks/${id}`, { method: 'DELETE' });
|
||||
|
||||
// --- Components ---
|
||||
export const getComponents = (rackId: string) => request<Component[]>(`/components?rackId=${rackId}`);
|
||||
export const getComponent = (id: string) => request<Component>(`/components/${id}`);
|
||||
export const createComponent = (data: Partial<Component>) =>
|
||||
request<Component>('/components', { method: 'POST', body: JSON.stringify(data) });
|
||||
export const updateComponent = (id: string, data: Partial<Component>) =>
|
||||
request<Component>(`/components/${id}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||
export const deleteComponent = (id: string) =>
|
||||
request(`/components/${id}`, { method: 'DELETE' });
|
||||
|
||||
// --- Ports ---
|
||||
export const getPorts = (componentId: string) =>
|
||||
request<Port[]>(`/components/${componentId}/ports`);
|
||||
export const createPort = (componentId: string, data: Partial<Port>) =>
|
||||
request<Port>(`/components/${componentId}/ports`, { method: 'POST', body: JSON.stringify(data) });
|
||||
export const updatePort = (componentId: string, portId: string, data: Partial<Port>) =>
|
||||
request<Port>(`/components/${componentId}/ports/${portId}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||
export const deletePort = (componentId: string, portId: string) =>
|
||||
request(`/components/${componentId}/ports/${portId}`, { method: 'DELETE' });
|
||||
|
||||
// --- Search ---
|
||||
export const search = (q: string) => request<SearchResults>(`/search?q=${encodeURIComponent(q)}`);
|
||||
|
||||
// --- Users ---
|
||||
export const getUsers = () => request<User[]>('/users');
|
||||
export const getUser = (id: string) => request<User>(`/users/${id}`);
|
||||
export const createUser = (data: { username: string; email?: string; role?: string }) =>
|
||||
request<User>('/users', { method: 'POST', body: JSON.stringify(data) });
|
||||
export const updateUser = (id: string, data: Partial<Pick<User, 'username' | 'email' | 'role' | 'is_active'>>) =>
|
||||
request<User>(`/users/${id}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||
export const deleteUser = (id: string) =>
|
||||
request(`/users/${id}`, { method: 'DELETE' });
|
||||
export const rotateApiKey = (id: string) =>
|
||||
request<{ api_key: string }>(`/users/${id}/api-key`, { method: 'POST' });
|
||||
export const revokeApiKey = (id: string) =>
|
||||
request(`/users/${id}/api-key`, { method: 'DELETE' });
|
||||
|
||||
// --- Audit ---
|
||||
export const getAuditLog = (params: {
|
||||
entity_type?: string; action?: string; user_id?: string;
|
||||
limit?: number; offset?: number;
|
||||
}) => {
|
||||
const qs = new URLSearchParams(
|
||||
Object.entries(params)
|
||||
.filter(([, v]) => v !== undefined && v !== '')
|
||||
.map(([k, v]) => [k, String(v)])
|
||||
).toString();
|
||||
return request<{ total: number; limit: number; offset: number; entries: AuditEntry[] }>(
|
||||
`/audit${qs ? `?${qs}` : ''}`
|
||||
);
|
||||
};
|
||||
export const clearAuditLog = () =>
|
||||
request('/settings/data/audit', { method: 'DELETE', headers: { 'Content-Type': 'application/json', 'X-Confirm': 'yes' } });
|
||||
|
||||
// --- Settings ---
|
||||
export const getDbStats = () => request<DbStats>('/settings/stats');
|
||||
export const deleteAllData = () =>
|
||||
request('/settings/data/all', { method: 'DELETE', headers: { 'Content-Type': 'application/json', 'X-Confirm': 'yes' } });
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { COMPONENT_META, COMPONENT_TYPES } from '../types';
|
||||
import type { ComponentType, ComponentStatus } from '../types';
|
||||
|
||||
interface AddComponentProps {
|
||||
rackId: string;
|
||||
totalUnits: number;
|
||||
initialPosition?: number;
|
||||
onSave: (data: ComponentFormData) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export interface ComponentFormData {
|
||||
rack_id: string;
|
||||
name: string;
|
||||
type: ComponentType;
|
||||
position?: number | null;
|
||||
height_units: number;
|
||||
port_count?: number | null;
|
||||
sfp_count?: number | null;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
serial_number?: string;
|
||||
asset_tag?: string;
|
||||
ip_address?: string;
|
||||
mac_address?: string;
|
||||
status: ComponentStatus;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export function AddComponentModal({ rackId, totalUnits, initialPosition, onSave, onClose }: AddComponentProps) {
|
||||
const [form, setForm] = useState<ComponentFormData>({
|
||||
rack_id: rackId,
|
||||
name: '',
|
||||
type: 'server',
|
||||
position: initialPosition ?? null,
|
||||
height_units: 1,
|
||||
port_count: null,
|
||||
sfp_count: null,
|
||||
manufacturer: '',
|
||||
model: '',
|
||||
serial_number: '',
|
||||
asset_tag: '',
|
||||
ip_address: '',
|
||||
mac_address: '',
|
||||
status: 'active',
|
||||
notes: '',
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
function set(key: keyof ComponentFormData, value: unknown) {
|
||||
setForm(f => ({ ...f, [key]: value }));
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!form.name.trim()) { setError('Name is required'); return; }
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
await onSave({
|
||||
...form,
|
||||
position: form.position ? Number(form.position) : null,
|
||||
height_units: Number(form.height_units),
|
||||
port_count: (form.type === 'switch' || form.type === 'patch_panel') && form.port_count
|
||||
? Number(form.port_count) : null,
|
||||
sfp_count: form.type === 'switch' && form.sfp_count ? Number(form.sfp_count) : null,
|
||||
manufacturer: form.manufacturer || undefined,
|
||||
model: form.model || undefined,
|
||||
serial_number: form.serial_number || undefined,
|
||||
asset_tag: form.asset_tag || undefined,
|
||||
ip_address: form.ip_address || undefined,
|
||||
mac_address: form.mac_address || undefined,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save');
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">Add Component</h2>
|
||||
<button className="modal-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="modal-form">
|
||||
<div className="form-row">
|
||||
<div className="form-group form-group-lg">
|
||||
<label>Name *</label>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={e => set('name', e.target.value)}
|
||||
placeholder="e.g. Core Switch SW-01"
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Type</label>
|
||||
<select value={form.type} onChange={e => set('type', e.target.value)} className="form-input">
|
||||
{COMPONENT_TYPES.map(t => (
|
||||
<option key={t} value={t}>{COMPONENT_META[t].label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Rack Position (U)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={totalUnits}
|
||||
value={form.position ?? ''}
|
||||
onChange={e => set('position', e.target.value ? Number(e.target.value) : null)}
|
||||
placeholder="auto"
|
||||
className="form-input"
|
||||
/>
|
||||
<span className="form-hint">Leave empty to leave unpositioned</span>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Height (U)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={totalUnits}
|
||||
value={form.height_units}
|
||||
onChange={e => set('height_units', Number(e.target.value))}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Status</label>
|
||||
<select value={form.status} onChange={e => set('status', e.target.value)} className="form-input">
|
||||
<option value="active">Active</option>
|
||||
<option value="maintenance">Maintenance</option>
|
||||
<option value="decommissioned">Decommissioned</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(form.type === 'switch' || form.type === 'patch_panel') && (
|
||||
<div className="form-row port-count-row">
|
||||
<div className="form-group form-group-lg">
|
||||
<label>
|
||||
{form.type === 'patch_panel' ? '🔌 Available Patches (port count)' : '🔌 Number of Switch Ports'}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={96}
|
||||
value={form.port_count ?? 24}
|
||||
onChange={e => set('port_count', Number(e.target.value))}
|
||||
className="form-input"
|
||||
placeholder="24"
|
||||
/>
|
||||
<span className="form-hint">
|
||||
{form.type === 'patch_panel'
|
||||
? 'Number of RJ45 jacks on the front panel (e.g. 12, 24, 48)'
|
||||
: 'Total ethernet ports shown on the rack diagram (e.g. 8, 16, 24, 48)'}
|
||||
</span>
|
||||
</div>
|
||||
{form.type === 'switch' && (
|
||||
<div className="form-group">
|
||||
<label>🔶 Fiber / SFP Ports</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={16}
|
||||
value={form.sfp_count ?? 0}
|
||||
onChange={e => set('sfp_count', Number(e.target.value))}
|
||||
className="form-input"
|
||||
placeholder="0"
|
||||
/>
|
||||
<span className="form-hint">SFP / fiber uplink slots (0–16)</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Manufacturer</label>
|
||||
<input type="text" value={form.manufacturer} onChange={e => set('manufacturer', e.target.value)} placeholder="e.g. Cisco" className="form-input" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Model</label>
|
||||
<input type="text" value={form.model} onChange={e => set('model', e.target.value)} placeholder="e.g. Catalyst 2960" className="form-input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>IP Address</label>
|
||||
<input type="text" value={form.ip_address} onChange={e => set('ip_address', e.target.value)} placeholder="192.168.1.1" className="form-input" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>MAC Address</label>
|
||||
<input type="text" value={form.mac_address} onChange={e => set('mac_address', e.target.value)} placeholder="AA:BB:CC:DD:EE:FF" className="form-input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Serial Number</label>
|
||||
<input type="text" value={form.serial_number} onChange={e => set('serial_number', e.target.value)} className="form-input" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Asset Tag</label>
|
||||
<input type="text" value={form.asset_tag} onChange={e => set('asset_tag', e.target.value)} className="form-input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="form-error">{error}</div>}
|
||||
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn-secondary" onClick={onClose}>Cancel</button>
|
||||
<button type="submit" className="btn-primary" disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Add Component'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Generic modal for creating sites/rooms/racks
|
||||
interface SimpleCreateProps {
|
||||
title: string;
|
||||
fields: { key: string; label: string; placeholder?: string; type?: string; defaultValue?: string | number }[];
|
||||
onSave: (data: Record<string, string | number>) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SimpleCreateModal({ title, fields, onSave, onClose }: SimpleCreateProps) {
|
||||
const [values, setValues] = useState<Record<string, string | number>>(
|
||||
Object.fromEntries(fields.map(f => [f.key, f.defaultValue ?? '']))
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
await onSave(values);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save');
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal modal-sm">
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">{title}</h2>
|
||||
<button className="modal-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="modal-form">
|
||||
{fields.map(f => (
|
||||
<div className="form-group" key={f.key}>
|
||||
<label>{f.label}</label>
|
||||
<input
|
||||
autoFocus={fields[0].key === f.key}
|
||||
type={f.type ?? 'text'}
|
||||
value={values[f.key] as string}
|
||||
onChange={e => setValues(v => ({ ...v, [f.key]: f.type === 'number' ? Number(e.target.value) : e.target.value }))}
|
||||
placeholder={f.placeholder}
|
||||
className="form-input"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{error && <div className="form-error">{error}</div>}
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn-secondary" onClick={onClose}>Cancel</button>
|
||||
<button type="submit" className="btn-primary" disabled={saving}>
|
||||
{saving ? 'Saving...' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
onSave?: (val: string) => void;
|
||||
autoSaveDelay?: number;
|
||||
}
|
||||
|
||||
type EditorMode = 'edit' | 'split' | 'preview';
|
||||
|
||||
export default function MarkdownEditor({ value, onChange, onSave, autoSaveDelay = 1500 }: Props) {
|
||||
const [mode, setMode] = useState<EditorMode>('split');
|
||||
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'unsaved'>('saved');
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastSaved = useRef(value);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onSave) return;
|
||||
if (value === lastSaved.current) { setSaveStatus('saved'); return; }
|
||||
setSaveStatus('unsaved');
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
saveTimer.current = setTimeout(async () => {
|
||||
setSaveStatus('saving');
|
||||
await onSave(value);
|
||||
lastSaved.current = value;
|
||||
setSaveStatus('saved');
|
||||
}, autoSaveDelay);
|
||||
return () => { if (saveTimer.current) clearTimeout(saveTimer.current); };
|
||||
}, [value, onSave, autoSaveDelay]);
|
||||
|
||||
return (
|
||||
<div className="md-editor">
|
||||
<div className="md-editor-toolbar">
|
||||
<div className="md-mode-tabs">
|
||||
{(['edit', 'split', 'preview'] as EditorMode[]).map(m => (
|
||||
<button
|
||||
key={m}
|
||||
className={`md-mode-btn ${mode === m ? 'active' : ''}`}
|
||||
onClick={() => setMode(m)}
|
||||
>
|
||||
{m.charAt(0).toUpperCase() + m.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="md-toolbar-actions">
|
||||
{onSave && (
|
||||
<span className={`save-status save-status-${saveStatus}`}>
|
||||
{saveStatus === 'saving' ? '⟳ Saving…' : saveStatus === 'unsaved' ? '● Unsaved' : '✓ Saved'}
|
||||
</span>
|
||||
)}
|
||||
<span className="md-help-hint">Markdown supported · GFM tables, checkboxes</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`md-editor-body md-mode-${mode}`}>
|
||||
{mode !== 'preview' && (
|
||||
<textarea
|
||||
className="md-textarea"
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder="Write notes in Markdown… # Heading **bold**, *italic*, `code` - [ ] Checklist item | Port | Device | |------|--------| | 1 | PC-01 |"
|
||||
spellCheck={false}
|
||||
/>
|
||||
)}
|
||||
{mode !== 'edit' && (
|
||||
<div className="md-preview">
|
||||
{value.trim() ? (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{value}</ReactMarkdown>
|
||||
) : (
|
||||
<div className="md-preview-empty">Nothing to preview yet. Start writing in the editor.</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { Rack, Component } from '../types';
|
||||
import { COMPONENT_META } from '../types';
|
||||
|
||||
const UNIT_PX = 44;
|
||||
|
||||
interface SlotItem {
|
||||
type: 'component' | 'empty';
|
||||
position: number;
|
||||
heightUnits: number;
|
||||
component?: Component;
|
||||
}
|
||||
|
||||
function buildSlots(totalUnits: number, components: Component[]): SlotItem[] {
|
||||
const byPos = new Map<number, Component>();
|
||||
for (const c of components) {
|
||||
if (c.position != null) {
|
||||
for (let u = c.position; u < c.position + c.height_units; u++) {
|
||||
byPos.set(u, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
const slots: SlotItem[] = [];
|
||||
const seen = new Set<string>();
|
||||
let u = 1;
|
||||
while (u <= totalUnits) {
|
||||
const comp = byPos.get(u);
|
||||
if (comp && !seen.has(comp.id)) {
|
||||
seen.add(comp.id);
|
||||
slots.push({ type: 'component', position: u, heightUnits: comp.height_units, component: comp });
|
||||
u += comp.height_units;
|
||||
} else if (!comp) {
|
||||
slots.push({ type: 'empty', position: u, heightUnits: 1 });
|
||||
u++;
|
||||
} else {
|
||||
u++;
|
||||
}
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
|
||||
// ─── Patch Panel ────────────────────────────────────────────────────────────
|
||||
function PatchPanelFace({ comp, heightPx }: { comp: Component; heightPx: number }) {
|
||||
const portCount = comp.port_count ?? 24;
|
||||
const showNums = portCount <= 48;
|
||||
const color = COMPONENT_META.patch_panel.color;
|
||||
|
||||
return (
|
||||
<div className="hw-face hw-pp" style={{ height: heightPx }}>
|
||||
<div className="hw-ports-main hw-pp-fullrow">
|
||||
<div className="hw-pp-row">
|
||||
{Array.from({ length: portCount }, (_, i) => (
|
||||
<div key={i} className="hw-rj45-wrap" title={`Port ${i + 1}`}>
|
||||
<div className="hw-rj45-jack" />
|
||||
{showNums && <span className="hw-rj45-num">{i + 1}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hw-face-labels">
|
||||
<span className="hw-lbl-type" style={{ color }}>PATCH</span>
|
||||
<span className="hw-lbl-name">{comp.name}</span>
|
||||
<span className="hw-lbl-count" style={{ color }}>{portCount}P</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Switch ──────────────────────────────────────────────────────────────────
|
||||
function SwitchFace({ comp, heightPx }: { comp: Component; heightPx: number }) {
|
||||
const portCount = comp.port_count ?? 24;
|
||||
const sfpCount = comp.sfp_count ?? 0;
|
||||
const color = COMPONENT_META.switch.color;
|
||||
const configuredNums = new Set((comp.ports ?? []).map(p => p.port_number));
|
||||
const pairCount = Math.ceil(portCount / 2);
|
||||
|
||||
return (
|
||||
<div className="hw-face hw-sw" style={{ height: heightPx }}>
|
||||
{/* Main body: RJ45 pairs + optional SFP column */}
|
||||
<div className="hw-sw-body">
|
||||
{/* RJ45 area — fills all space left of the SFP column */}
|
||||
<div className="hw-sw-rj45-area">
|
||||
<div className="hw-sw-pairs-row">
|
||||
{Array.from({ length: pairCount }, (_, col) => {
|
||||
const top = col * 2 + 1;
|
||||
const bot = col * 2 + 2;
|
||||
const topActive = configuredNums.has(top);
|
||||
const botActive = bot <= portCount && configuredNums.has(bot);
|
||||
return (
|
||||
<div key={col} className="hw-sw-pair">
|
||||
<div className={`hw-sw-jack2${topActive ? ' hw-sw-jack2-active' : ''}`} title={`Port ${top}`}>
|
||||
<div className={`hw-sw-led2 ${topActive ? 'hw-led-green' : 'hw-led-off'}`} />
|
||||
</div>
|
||||
{bot <= portCount ? (
|
||||
<div className={`hw-sw-jack2${botActive ? ' hw-sw-jack2-active' : ''}`} title={`Port ${bot}`}>
|
||||
<div className={`hw-sw-led2 ${botActive ? 'hw-led-green' : 'hw-led-off'}`} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="hw-sw-jack2 hw-sw-jack2-empty" />
|
||||
)}
|
||||
<span className="hw-sw-num2">{top}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SFP column — only shown when sfp_count > 0 */}
|
||||
{sfpCount > 0 && (
|
||||
<div className="hw-sw-sfp-col" title={`${sfpCount} SFP/fiber uplink slots`}>
|
||||
<span className="hw-sw-sfp-title">SFP</span>
|
||||
{Array.from({ length: sfpCount }, (_, i) => (
|
||||
<div key={i} className="hw-sw-sfp-slot" title={`SFP ${i + 1}`}>
|
||||
<div className="hw-led hw-led-off" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="hw-face-labels">
|
||||
<span className="hw-lbl-type" style={{ color }}>SWITCH</span>
|
||||
<span className="hw-lbl-name">{comp.name}</span>
|
||||
{comp.ip_address && <span className="hw-lbl-ip">{comp.ip_address}</span>}
|
||||
<span className="hw-lbl-count" style={{ color }}>{portCount}P{sfpCount > 0 ? ` +${sfpCount}F` : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Server ──────────────────────────────────────────────────────────────────
|
||||
function ServerFace({ comp, heightPx }: { comp: Component; heightPx: number }) {
|
||||
const color = COMPONENT_META.server.color;
|
||||
const driveCount = Math.min(8, comp.height_units * 4);
|
||||
|
||||
return (
|
||||
<div className="hw-face hw-server" style={{ height: heightPx }}>
|
||||
<div className="hw-ports-main hw-server-body">
|
||||
<div className="hw-drive-bays">
|
||||
{Array.from({ length: driveCount }, (_, i) => (
|
||||
<div key={i} className="hw-drive-bay" title={`Drive ${i + 1}`} />
|
||||
))}
|
||||
</div>
|
||||
<div className="hw-server-panel">
|
||||
<div className="hw-power-btn" title="Power" />
|
||||
<div className="hw-led hw-led-green" title="Online" />
|
||||
<div className="hw-led hw-led-amber" title="Activity" />
|
||||
<div className="hw-usb" title="USB" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="hw-face-labels">
|
||||
<span className="hw-lbl-type" style={{ color }}>SERVER</span>
|
||||
<span className="hw-lbl-name">{comp.name}</span>
|
||||
{comp.model && <span className="hw-lbl-model">{comp.model}</span>}
|
||||
{comp.ip_address && <span className="hw-lbl-ip">{comp.ip_address}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Firewall ────────────────────────────────────────────────────────────────
|
||||
function FirewallFace({ comp, heightPx }: { comp: Component; heightPx: number }) {
|
||||
const color = COMPONENT_META.firewall.color;
|
||||
|
||||
return (
|
||||
<div className="hw-face hw-fw" style={{ height: heightPx }}>
|
||||
<div className="hw-ports-main">
|
||||
<div className="hw-sw-row">
|
||||
<div className="hw-sw-port-wrap" title="WAN">
|
||||
<div className="hw-led hw-led-amber" />
|
||||
<div className="hw-eth-port hw-eth-wan" />
|
||||
</div>
|
||||
<div className="hw-sw-gap" />
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<div key={i} className="hw-sw-port-wrap" title={`LAN ${i + 1}`}>
|
||||
<div className="hw-led hw-led-green" />
|
||||
<div className="hw-eth-port" />
|
||||
</div>
|
||||
))}
|
||||
<div className="hw-sw-gap" />
|
||||
<div className="hw-sw-port-wrap" title="DMZ">
|
||||
<div className="hw-led hw-led-off" />
|
||||
<div className="hw-eth-port" style={{ borderColor: '#6b7280' }} />
|
||||
</div>
|
||||
<div className="hw-sw-gap" />
|
||||
<div className="hw-usb" title="Console" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="hw-face-labels">
|
||||
<span className="hw-lbl-type" style={{ color }}>FIREWALL</span>
|
||||
<span className="hw-lbl-name">{comp.name}</span>
|
||||
{comp.ip_address && <span className="hw-lbl-ip">{comp.ip_address}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Router ──────────────────────────────────────────────────────────────────
|
||||
function RouterFace({ comp, heightPx }: { comp: Component; heightPx: number }) {
|
||||
const color = COMPONENT_META.router.color;
|
||||
|
||||
return (
|
||||
<div className="hw-face hw-router" style={{ height: heightPx }}>
|
||||
<div className="hw-ports-main">
|
||||
<div className="hw-sw-row">
|
||||
<div className="hw-sw-port-wrap" title="WAN">
|
||||
<div className="hw-led hw-led-amber" />
|
||||
<div className="hw-eth-port hw-eth-wan" />
|
||||
</div>
|
||||
<div className="hw-sw-gap" />
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<div key={i} className="hw-sw-port-wrap" title={`LAN ${i + 1}`}>
|
||||
<div className="hw-led hw-led-green" />
|
||||
<div className="hw-eth-port" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hw-face-labels">
|
||||
<span className="hw-lbl-type" style={{ color }}>ROUTER</span>
|
||||
<span className="hw-lbl-name">{comp.name}</span>
|
||||
{comp.ip_address && <span className="hw-lbl-ip">{comp.ip_address}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── UPS ─────────────────────────────────────────────────────────────────────
|
||||
function UpsFace({ comp, heightPx }: { comp: Component; heightPx: number }) {
|
||||
const color = COMPONENT_META.ups.color;
|
||||
|
||||
return (
|
||||
<div className="hw-face hw-ups" style={{ height: heightPx }}>
|
||||
<div className="hw-ports-main hw-ups-body">
|
||||
<div className="hw-ups-screen">
|
||||
<div className="hw-ups-bar-outer">
|
||||
<div className="hw-ups-bar-inner" style={{ width: '80%' }} />
|
||||
</div>
|
||||
<span className="hw-ups-pct">100%</span>
|
||||
</div>
|
||||
<div className="hw-ups-indicators">
|
||||
<div className="hw-led hw-led-green" title="Online" />
|
||||
<div className="hw-led hw-led-amber" title="Battery" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="hw-face-labels">
|
||||
<span className="hw-lbl-type" style={{ color }}>UPS</span>
|
||||
<span className="hw-lbl-name">{comp.name}</span>
|
||||
{comp.model && <span className="hw-lbl-model">{comp.model}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── PDU ─────────────────────────────────────────────────────────────────────
|
||||
function PduFace({ comp, heightPx }: { comp: Component; heightPx: number }) {
|
||||
const color = COMPONENT_META.pdu.color;
|
||||
|
||||
return (
|
||||
<div className="hw-face hw-pdu" style={{ height: heightPx }}>
|
||||
<div className="hw-ports-main">
|
||||
<div className="hw-sw-row">
|
||||
{Array.from({ length: 8 }, (_, i) => (
|
||||
<div key={i} className="hw-pdu-outlet" title={`Outlet ${i + 1}`}>
|
||||
<div className="hw-led hw-led-green" />
|
||||
<div className="hw-outlet-socket" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hw-face-labels">
|
||||
<span className="hw-lbl-type" style={{ color }}>PDU</span>
|
||||
<span className="hw-lbl-name">{comp.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── KVM ─────────────────────────────────────────────────────────────────────
|
||||
function KvmFace({ comp, heightPx }: { comp: Component; heightPx: number }) {
|
||||
const color = COMPONENT_META.kvm.color;
|
||||
|
||||
return (
|
||||
<div className="hw-face hw-kvm" style={{ height: heightPx }}>
|
||||
<div className="hw-ports-main hw-server-body">
|
||||
<div className="hw-kvm-ports">
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<div key={i} className="hw-sw-port-wrap" title={`KVM ${i + 1}`}>
|
||||
<div className="hw-led hw-led-off" />
|
||||
<div className="hw-eth-port" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="hw-server-panel">
|
||||
<div className="hw-led hw-led-green" />
|
||||
<div className="hw-usb" />
|
||||
<div className="hw-usb" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="hw-face-labels">
|
||||
<span className="hw-lbl-type" style={{ color }}>KVM</span>
|
||||
<span className="hw-lbl-name">{comp.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Storage ─────────────────────────────────────────────────────────────────
|
||||
function StorageFace({ comp, heightPx }: { comp: Component; heightPx: number }) {
|
||||
const color = COMPONENT_META.storage.color;
|
||||
const driveCount = Math.min(12, comp.height_units * 4);
|
||||
|
||||
return (
|
||||
<div className="hw-face hw-storage" style={{ height: heightPx }}>
|
||||
<div className="hw-ports-main hw-server-body">
|
||||
<div className="hw-drive-bays">
|
||||
{Array.from({ length: driveCount }, (_, i) => (
|
||||
<div key={i} className="hw-drive-bay hw-drive-bay-storage" title={`Disk ${i + 1}`} />
|
||||
))}
|
||||
</div>
|
||||
<div className="hw-server-panel">
|
||||
<div className="hw-power-btn" />
|
||||
<div className="hw-led hw-led-green" />
|
||||
<div className="hw-led hw-led-amber" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="hw-face-labels">
|
||||
<span className="hw-lbl-type" style={{ color }}>STORAGE</span>
|
||||
<span className="hw-lbl-name">{comp.name}</span>
|
||||
{comp.model && <span className="hw-lbl-model">{comp.model}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Default ─────────────────────────────────────────────────────────────────
|
||||
function DefaultFace({ comp, heightPx }: { comp: Component; heightPx: number }) {
|
||||
const meta = COMPONENT_META[comp.type];
|
||||
return (
|
||||
<div className="hw-face" style={{ height: heightPx, background: meta.bg, borderLeft: `4px solid ${meta.color}` }}>
|
||||
<div className="hw-ports-main hw-default-body">
|
||||
<div className="hw-indicators-col">
|
||||
<div className="hw-led hw-led-green" />
|
||||
{comp.height_units > 1 && <div className="hw-led hw-led-amber" />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hw-face-labels">
|
||||
<span className="hw-lbl-type" style={{ color: meta.color }}>{meta.label.toUpperCase()}</span>
|
||||
<span className="hw-lbl-name">{comp.name}</span>
|
||||
{comp.ip_address && <span className="hw-lbl-ip">{comp.ip_address}</span>}
|
||||
{comp.model && <span className="hw-lbl-model">{comp.model}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderFace(comp: Component, heightPx: number): React.ReactNode {
|
||||
switch (comp.type) {
|
||||
case 'patch_panel': return <PatchPanelFace comp={comp} heightPx={heightPx} />;
|
||||
case 'switch': return <SwitchFace comp={comp} heightPx={heightPx} />;
|
||||
case 'server': return <ServerFace comp={comp} heightPx={heightPx} />;
|
||||
case 'firewall': return <FirewallFace comp={comp} heightPx={heightPx} />;
|
||||
case 'router': return <RouterFace comp={comp} heightPx={heightPx} />;
|
||||
case 'ups': return <UpsFace comp={comp} heightPx={heightPx} />;
|
||||
case 'pdu': return <PduFace comp={comp} heightPx={heightPx} />;
|
||||
case 'kvm': return <KvmFace comp={comp} heightPx={heightPx} />;
|
||||
case 'storage': return <StorageFace comp={comp} heightPx={heightPx} />;
|
||||
default: return <DefaultFace comp={comp} heightPx={heightPx} />;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Main Component ──────────────────────────────────────────────────────────
|
||||
interface Props {
|
||||
rack: Rack;
|
||||
components: Component[];
|
||||
onAddAtSlot?: (position: number) => void;
|
||||
}
|
||||
|
||||
export default function RackGraphicView({ rack, components, onAddAtSlot }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const slots = buildSlots(rack.total_units, components);
|
||||
|
||||
return (
|
||||
<div className="rack-gfx">
|
||||
<div className="rack-gfx-cabinet">
|
||||
{/* Top strip */}
|
||||
<div className="rack-gfx-top">
|
||||
<span className="rack-gfx-title">{rack.name}</span>
|
||||
{rack.manufacturer && (
|
||||
<span className="rack-gfx-sub">{rack.manufacturer} {rack.model}</span>
|
||||
)}
|
||||
<span className="rack-gfx-units-badge">{rack.total_units}U</span>
|
||||
</div>
|
||||
|
||||
{/* Slots */}
|
||||
<div className="rack-gfx-body">
|
||||
{slots.map(slot => {
|
||||
const heightPx = slot.heightUnits * UNIT_PX;
|
||||
|
||||
if (slot.type === 'empty') {
|
||||
return (
|
||||
<div
|
||||
key={`e-${slot.position}`}
|
||||
className="rack-gfx-slot rack-gfx-empty"
|
||||
style={{ height: heightPx }}
|
||||
onClick={() => onAddAtSlot?.(slot.position)}
|
||||
>
|
||||
<span className="rack-gfx-u">{slot.position}</span>
|
||||
<span className="rack-gfx-empty-txt">· empty ·</span>
|
||||
<span className="rack-gfx-add-hint">+ add</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const comp = slot.component!;
|
||||
return (
|
||||
<div
|
||||
key={comp.id}
|
||||
className="rack-gfx-slot rack-gfx-occupied"
|
||||
style={{ height: heightPx }}
|
||||
onClick={() => navigate(`/components/${comp.id}`)}
|
||||
title={`${comp.name} — click to open`}
|
||||
>
|
||||
<span className="rack-gfx-u">{slot.position}</span>
|
||||
<div className="rack-gfx-face">
|
||||
{renderFace(comp, heightPx)}
|
||||
</div>
|
||||
<div className="rack-gfx-screws">
|
||||
<div className="rack-gfx-screw" />
|
||||
{slot.heightUnits > 1 && <div className="rack-gfx-screw" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Bottom strip */}
|
||||
<div className="rack-gfx-bottom" />
|
||||
</div>
|
||||
|
||||
{/* Unpositioned */}
|
||||
{components.filter(c => c.position == null).length > 0 && (
|
||||
<div className="rack-unpositioned" style={{ marginTop: 16 }}>
|
||||
<div className="rack-unpositioned-title">Unpositioned Components</div>
|
||||
<div className="rack-unpositioned-list">
|
||||
{components.filter(c => c.position == null).map(comp => {
|
||||
const meta = COMPONENT_META[comp.type];
|
||||
return (
|
||||
<div
|
||||
key={comp.id}
|
||||
className="rack-unpositioned-item"
|
||||
style={{ borderLeft: `3px solid ${meta.color}` }}
|
||||
onClick={() => navigate(`/components/${comp.id}`)}
|
||||
>
|
||||
<span style={{ color: meta.color, fontSize: 11 }}>{meta.label}</span>
|
||||
<span className="rack-unpositioned-name">{comp.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { Rack, Component } from '../types';
|
||||
import { COMPONENT_META } from '../types';
|
||||
|
||||
interface Props {
|
||||
rack: Rack;
|
||||
components: Component[];
|
||||
onAddAtSlot?: (position: number) => void;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
interface SlotItem {
|
||||
type: 'component' | 'empty';
|
||||
component?: Component;
|
||||
position: number;
|
||||
heightUnits: number;
|
||||
}
|
||||
|
||||
function buildSlots(totalUnits: number, components: Component[]): SlotItem[] {
|
||||
// Map each U position to the component occupying it
|
||||
const byPosition = new Map<number, Component>();
|
||||
for (const c of components) {
|
||||
if (c.position != null) {
|
||||
for (let u = c.position; u < c.position + c.height_units; u++) {
|
||||
byPosition.set(u, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const items: SlotItem[] = [];
|
||||
const seen = new Set<string>();
|
||||
let u = 1;
|
||||
|
||||
while (u <= totalUnits) {
|
||||
const comp = byPosition.get(u);
|
||||
if (comp && !seen.has(comp.id)) {
|
||||
seen.add(comp.id);
|
||||
items.push({ type: 'component', component: comp, position: comp.position!, heightUnits: comp.height_units });
|
||||
u += comp.height_units;
|
||||
} else if (!comp) {
|
||||
items.push({ type: 'empty', position: u, heightUnits: 1 });
|
||||
u++;
|
||||
} else {
|
||||
// occupied by a comp already rendered
|
||||
u++;
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
const UNIT_HEIGHT = 30; // px per U
|
||||
|
||||
export default function RackVisual({ rack, components, onAddAtSlot }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const slots = buildSlots(rack.total_units, components);
|
||||
|
||||
const usedUnits = components.reduce((sum, c) => sum + c.height_units, 0);
|
||||
const freeUnits = rack.total_units - usedUnits;
|
||||
|
||||
return (
|
||||
<div className="rack-wrapper">
|
||||
<div className="rack-stats-bar">
|
||||
<span className="rack-stat"><strong>{rack.total_units}U</strong> total</span>
|
||||
<span className="rack-stat rack-stat-used"><strong>{usedUnits}U</strong> used</span>
|
||||
<span className="rack-stat rack-stat-free"><strong>{freeUnits}U</strong> free</span>
|
||||
<span className="rack-stat">{components.length} devices</span>
|
||||
</div>
|
||||
|
||||
<div className="rack-cabinet">
|
||||
{/* Rack top strip */}
|
||||
<div className="rack-top-strip">
|
||||
<span className="rack-label-text">{rack.name}</span>
|
||||
{rack.manufacturer && <span className="rack-label-sub">{rack.manufacturer} {rack.model}</span>}
|
||||
</div>
|
||||
|
||||
{/* Rack body */}
|
||||
<div className="rack-body">
|
||||
{slots.map(slot => {
|
||||
const heightPx = slot.heightUnits * UNIT_HEIGHT;
|
||||
|
||||
if (slot.type === 'empty') {
|
||||
return (
|
||||
<div
|
||||
key={`empty-${slot.position}`}
|
||||
className="rack-slot rack-slot-empty"
|
||||
style={{ height: `${heightPx}px` }}
|
||||
title={`Slot ${slot.position}U — click to add component`}
|
||||
onClick={() => onAddAtSlot?.(slot.position)}
|
||||
>
|
||||
<span className="rack-unit-num">{slot.position}</span>
|
||||
<span className="rack-empty-label">· empty ·</span>
|
||||
<span className="rack-add-hint">+ add</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const comp = slot.component!;
|
||||
const meta = COMPONENT_META[comp.type];
|
||||
const statusDot = comp.status === 'active' ? '🟢' : comp.status === 'maintenance' ? '🟡' : '🔴';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={comp.id}
|
||||
className="rack-slot rack-slot-component"
|
||||
style={{
|
||||
height: `${heightPx}px`,
|
||||
backgroundColor: meta.bg,
|
||||
borderLeft: `4px solid ${meta.color}`,
|
||||
borderBottom: `1px solid ${meta.border}`,
|
||||
}}
|
||||
title={`${comp.name} — ${meta.label} (${comp.height_units}U) — Click to view`}
|
||||
onClick={() => navigate(`/components/${comp.id}`)}
|
||||
>
|
||||
<span className="rack-unit-num" style={{ color: meta.color }}>
|
||||
{slot.position}
|
||||
</span>
|
||||
<div className="rack-component-body">
|
||||
<div className="rack-component-top">
|
||||
<span className="rack-component-type-badge" style={{ color: meta.color }}>
|
||||
{meta.label}
|
||||
</span>
|
||||
<span className="rack-component-name" style={{ color: '#f1f5f9' }}>
|
||||
{comp.name}
|
||||
</span>
|
||||
<span className="rack-component-status">{statusDot}</span>
|
||||
</div>
|
||||
{comp.height_units > 1 && (
|
||||
<div className="rack-component-sub">
|
||||
{comp.model && <span className="rack-component-model">{comp.model}</span>}
|
||||
{comp.ip_address && <span className="rack-component-ip">{comp.ip_address}</span>}
|
||||
<span className="rack-component-units" style={{ color: meta.color }}>{comp.height_units}U</span>
|
||||
</div>
|
||||
)}
|
||||
{comp.height_units === 1 && (
|
||||
<>
|
||||
{comp.model && (
|
||||
<span className="rack-component-model-inline"> · {comp.model}</span>
|
||||
)}
|
||||
{comp.ip_address && (
|
||||
<span className="rack-component-ip-inline"> · {comp.ip_address}</span>
|
||||
)}
|
||||
<span className="rack-component-units-inline" style={{ color: meta.color }}>
|
||||
{' '}{comp.height_units}U
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Rack screw holes */}
|
||||
<div className="rack-screw-col">
|
||||
<div className="rack-screw" />
|
||||
{slot.heightUnits > 1 && <div className="rack-screw" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Rack bottom strip */}
|
||||
<div className="rack-bottom-strip" />
|
||||
</div>
|
||||
|
||||
{/* Unpositioned components */}
|
||||
{components.filter(c => c.position == null).length > 0 && (
|
||||
<div className="rack-unpositioned">
|
||||
<h4 className="rack-unpositioned-title">Unpositioned Components</h4>
|
||||
<div className="rack-unpositioned-list">
|
||||
{components.filter(c => c.position == null).map(comp => {
|
||||
const meta = COMPONENT_META[comp.type];
|
||||
return (
|
||||
<div
|
||||
key={comp.id}
|
||||
className="rack-unpositioned-item"
|
||||
style={{ borderLeft: `3px solid ${meta.color}` }}
|
||||
onClick={() => navigate(`/components/${comp.id}`)}
|
||||
>
|
||||
<span style={{ color: meta.color }}>{meta.label}</span>
|
||||
<span className="rack-unpositioned-name">{comp.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import * as api from '../api';
|
||||
import type { Site, Room, Rack } from '../types';
|
||||
|
||||
interface TreeRoom extends Room {
|
||||
racks: Rack[];
|
||||
expanded: boolean;
|
||||
}
|
||||
interface TreeSite extends Site {
|
||||
rooms: TreeRoom[];
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
export default function Sidebar() {
|
||||
const [tree, setTree] = useState<TreeSite[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<Awaited<ReturnType<typeof api.search>> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [addSiteName, setAddSiteName] = useState('');
|
||||
const [showAddSite, setShowAddSite] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const loadTree = useCallback(async () => {
|
||||
const sites = await api.getSites();
|
||||
setTree(sites.map(s => ({ ...s, rooms: [], expanded: false })));
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadTree(); }, [loadTree]);
|
||||
|
||||
const expandSite = async (siteId: string) => {
|
||||
setTree(prev => prev.map(s => {
|
||||
if (s.id !== siteId) return s;
|
||||
if (s.expanded) return { ...s, expanded: false };
|
||||
return { ...s, expanded: true };
|
||||
}));
|
||||
// Load rooms if not loaded
|
||||
setTree(prev => {
|
||||
const site = prev.find(s => s.id === siteId);
|
||||
if (site && site.rooms.length === 0) {
|
||||
api.getSite(siteId).then(full => {
|
||||
setTree(p => p.map(s => s.id === siteId
|
||||
? { ...s, rooms: (full.rooms ?? []).map(r => ({ ...r, racks: [], expanded: false })) }
|
||||
: s
|
||||
));
|
||||
});
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
};
|
||||
|
||||
const expandRoom = async (siteId: string, roomId: string) => {
|
||||
setTree(prev => prev.map(s => s.id !== siteId ? s : {
|
||||
...s,
|
||||
rooms: s.rooms.map(r => {
|
||||
if (r.id !== roomId) return r;
|
||||
if (r.expanded) return { ...r, expanded: false };
|
||||
// Load racks
|
||||
if (r.racks.length === 0) {
|
||||
api.getRacks(roomId).then(racks => {
|
||||
setTree(p => p.map(ss => ss.id !== siteId ? ss : {
|
||||
...ss,
|
||||
rooms: ss.rooms.map(rr => rr.id === roomId ? { ...rr, racks } : rr),
|
||||
}));
|
||||
});
|
||||
}
|
||||
return { ...r, expanded: true };
|
||||
}),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAddSite = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!addSiteName.trim()) return;
|
||||
const site = await api.createSite({ name: addSiteName.trim() });
|
||||
setAddSiteName('');
|
||||
setShowAddSite(false);
|
||||
await loadTree();
|
||||
navigate(`/sites/${site.id}`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!search.trim()) { setSearchResults(null); return; }
|
||||
const t = setTimeout(async () => {
|
||||
const r = await api.search(search);
|
||||
setSearchResults(r);
|
||||
}, 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [search]);
|
||||
|
||||
const isActive = (path: string) => location.pathname === path;
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<Link to="/" className="sidebar-logo">
|
||||
<span className="logo-icon">⬛</span>
|
||||
<span className="logo-text">NetworkView</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="search-input"
|
||||
/>
|
||||
{searchResults && (
|
||||
<div className="search-results">
|
||||
{searchResults.sites.map(s => (
|
||||
<Link key={s.id} to={`/sites/${s.id}`} className="search-result-item" onClick={() => setSearch('')}>
|
||||
<span className="search-result-icon">🏢</span> {s.name}
|
||||
</Link>
|
||||
))}
|
||||
{searchResults.rooms.map(r => (
|
||||
<Link key={r.id} to={`/rooms/${r.id}`} className="search-result-item" onClick={() => setSearch('')}>
|
||||
<span className="search-result-icon">🚪</span> {r.name}
|
||||
</Link>
|
||||
))}
|
||||
{searchResults.racks.map(r => (
|
||||
<Link key={r.id} to={`/racks/${r.id}`} className="search-result-item" onClick={() => setSearch('')}>
|
||||
<span className="search-result-icon">🗄️</span> {r.name}
|
||||
</Link>
|
||||
))}
|
||||
{searchResults.components.map(c => (
|
||||
<Link key={c.id} to={`/components/${c.id}`} className="search-result-item" onClick={() => setSearch('')}>
|
||||
<span className="search-result-icon">🔧</span> {c.name}
|
||||
{c.ip_address && <span className="search-result-sub"> — {c.ip_address}</span>}
|
||||
</Link>
|
||||
))}
|
||||
{!searchResults.sites.length && !searchResults.rooms.length &&
|
||||
!searchResults.racks.length && !searchResults.components.length && (
|
||||
<div className="search-no-results">No results found</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className="sidebar-nav">
|
||||
<div className="sidebar-section-header">
|
||||
<span>Sites</span>
|
||||
<button className="icon-btn" title="Add site" onClick={() => setShowAddSite(v => !v)}>+</button>
|
||||
</div>
|
||||
|
||||
{showAddSite && (
|
||||
<form onSubmit={handleAddSite} className="inline-add-form">
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
placeholder="Site name..."
|
||||
value={addSiteName}
|
||||
onChange={e => setAddSiteName(e.target.value)}
|
||||
className="inline-input"
|
||||
/>
|
||||
<button type="submit" className="inline-btn">Add</button>
|
||||
<button type="button" className="inline-btn secondary" onClick={() => setShowAddSite(false)}>✕</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{loading && <div className="sidebar-loading">Loading...</div>}
|
||||
|
||||
{tree.map(site => (
|
||||
<div key={site.id} className="tree-node">
|
||||
<div className={`tree-item tree-site ${isActive(`/sites/${site.id}`) ? 'active' : ''}`}>
|
||||
<button className="tree-expand" onClick={() => expandSite(site.id)}>
|
||||
{site.expanded ? '▾' : '▸'}
|
||||
</button>
|
||||
<Link to={`/sites/${site.id}`} className="tree-label">
|
||||
<span className="tree-icon">🏢</span>
|
||||
<span className="tree-name">{site.name}</span>
|
||||
{site.room_count != null && (
|
||||
<span className="tree-count">{site.room_count}</span>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{site.expanded && (
|
||||
<div className="tree-children">
|
||||
{site.rooms.map(room => (
|
||||
<div key={room.id} className="tree-node">
|
||||
<div className={`tree-item tree-room ${isActive(`/rooms/${room.id}`) ? 'active' : ''}`}>
|
||||
<button className="tree-expand" onClick={() => expandRoom(site.id, room.id)}>
|
||||
{room.expanded ? '▾' : '▸'}
|
||||
</button>
|
||||
<Link to={`/rooms/${room.id}`} className="tree-label">
|
||||
<span className="tree-icon">🚪</span>
|
||||
<span className="tree-name">{room.name}</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{room.expanded && (
|
||||
<div className="tree-children">
|
||||
{room.racks.map(rack => (
|
||||
<div key={rack.id} className={`tree-item tree-rack ${isActive(`/racks/${rack.id}`) ? 'active' : ''}`}>
|
||||
<Link to={`/racks/${rack.id}`} className="tree-label">
|
||||
<span className="tree-icon">🗄️</span>
|
||||
<span className="tree-name">{rack.name}</span>
|
||||
<span className="tree-count">{rack.total_units}U</span>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{site.rooms.length === 0 && (
|
||||
<div className="tree-empty">No rooms yet</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!loading && tree.length === 0 && (
|
||||
<div className="sidebar-empty">
|
||||
<p>No sites yet.</p>
|
||||
<button className="btn-primary btn-sm" onClick={() => setShowAddSite(true)}>
|
||||
+ Add your first site
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<Link to="/settings" className={`sidebar-footer-link ${isActive('/settings') ? 'active' : ''}`}>
|
||||
⚙ Settings
|
||||
</Link>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,647 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import * as api from '../api';
|
||||
import type { Component, Port } from '../types';
|
||||
import { COMPONENT_META, COMPONENT_TYPES } from '../types';
|
||||
import MarkdownEditor from '../components/MarkdownEditor';
|
||||
|
||||
const STATUS_OPTIONS = ['active', 'maintenance', 'decommissioned'] as const;
|
||||
|
||||
export default function ComponentPage() {
|
||||
const { componentId } = useParams<{ componentId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [component, setComponent] = useState<Component | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [form, setForm] = useState<Partial<Component>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
type PortModalState = { editPort: Port | null };
|
||||
const BLANK_PORT_FORM = { port_number: '', label: '', port_type: 'RJ45', notes: '', connected_to_port_id: '' };
|
||||
const [portModal, setPortModal] = useState<PortModalState | null>(null);
|
||||
const [portForm, setPortForm] = useState(BLANK_PORT_FORM);
|
||||
const [patchPanels, setPatchPanels] = useState<Component[]>([]);
|
||||
const [switches, setSwitches] = useState<Component[]>([]);
|
||||
const [swLink, setSwLink] = useState<{ switchId: string; portNumber: string; portId: string | null } | null>(null);
|
||||
const [portError, setPortError] = useState<string | null>(null);
|
||||
|
||||
const loadComponent = useCallback(async () => {
|
||||
if (!componentId) return;
|
||||
const c = await api.getComponent(componentId);
|
||||
setComponent(c);
|
||||
setNotes(c.notes ?? '');
|
||||
setForm({
|
||||
name: c.name, type: c.type, position: c.position, height_units: c.height_units,
|
||||
manufacturer: c.manufacturer, model: c.model, serial_number: c.serial_number,
|
||||
asset_tag: c.asset_tag, ip_address: c.ip_address, mac_address: c.mac_address, status: c.status,
|
||||
port_count: c.port_count,
|
||||
sfp_count: c.sfp_count,
|
||||
});
|
||||
setLoading(false);
|
||||
if (c.type === 'switch' && c.rack?.id) {
|
||||
const all = await api.getComponents(c.rack.id);
|
||||
setPatchPanels(all.filter(comp => comp.type === 'patch_panel'));
|
||||
}
|
||||
if (c.type === 'patch_panel' && c.rack?.id) {
|
||||
const all = await api.getComponents(c.rack.id);
|
||||
setSwitches(all.filter(comp => comp.type === 'switch'));
|
||||
}
|
||||
}, [componentId]);
|
||||
|
||||
useEffect(() => { loadComponent(); }, [loadComponent]);
|
||||
|
||||
const saveNotes = async (val: string) => {
|
||||
if (!componentId) return;
|
||||
await api.updateComponent(componentId, { notes: val });
|
||||
};
|
||||
|
||||
const saveMeta = async () => {
|
||||
if (!componentId) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.updateComponent(componentId, form);
|
||||
await loadComponent();
|
||||
setEditing(false);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!componentId || !component || !confirm(`Delete component "${component.name}"?`)) return;
|
||||
await api.deleteComponent(componentId);
|
||||
navigate(component.rack ? `/racks/${component.rack.id}` : '/');
|
||||
};
|
||||
|
||||
const handleDeletePort = async (portId: string) => {
|
||||
if (!componentId || !confirm('Delete this port?')) return;
|
||||
await api.deletePort(componentId, portId);
|
||||
await loadComponent();
|
||||
};
|
||||
|
||||
const openPortModal = (port?: Port) => {
|
||||
setPortError(null);
|
||||
setPortModal({ editPort: port ?? null });
|
||||
setPortForm(port ? {
|
||||
port_number: String(port.port_number),
|
||||
label: port.label ?? '',
|
||||
port_type: port.port_type,
|
||||
notes: port.notes ?? '',
|
||||
connected_to_port_id: port.connected_to_port_id ?? '',
|
||||
} : BLANK_PORT_FORM);
|
||||
if (port?.linked_port?.component_type === 'switch') {
|
||||
setSwLink({ switchId: port.linked_port.component_id, portNumber: String(port.linked_port.port_number), portId: port.linked_port.id });
|
||||
} else {
|
||||
setSwLink(null);
|
||||
}
|
||||
};
|
||||
const closePortModal = () => { setPortModal(null); setSwLink(null); setPortError(null); };
|
||||
|
||||
const handleSavePort = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!componentId || !portForm.port_number) return;
|
||||
setPortError(null);
|
||||
|
||||
// Patch-panel only: validate no duplicate port number and capacity limit
|
||||
if (component?.type === 'patch_panel' && !portModal?.editPort) {
|
||||
const existingPorts: Port[] = component.ports ?? [];
|
||||
const portNum = Number(portForm.port_number);
|
||||
const maxPorts = component.port_count ?? Infinity;
|
||||
|
||||
if (existingPorts.some(p => p.port_number === portNum)) {
|
||||
setPortError(`Port ${portNum} is already added. Each port number can only appear once.`);
|
||||
return;
|
||||
}
|
||||
if (existingPorts.length >= maxPorts) {
|
||||
setPortError(`This patch panel is full (${maxPorts} ports). Delete an existing port to add a new one.`);
|
||||
return;
|
||||
}
|
||||
if (portNum < 1 || portNum > maxPorts) {
|
||||
setPortError(`Port number must be between 1 and ${maxPorts}.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve connected_to_port_id for patch panels via swLink
|
||||
let connectedToPortId: string | null | undefined = portForm.connected_to_port_id || null;
|
||||
if (component?.type === 'patch_panel') {
|
||||
if (swLink?.switchId && swLink?.portNumber) {
|
||||
if (swLink.portId) {
|
||||
connectedToPortId = swLink.portId;
|
||||
} else {
|
||||
// Switch port record doesn't exist yet – create it first
|
||||
const newSwPort = await api.createPort(swLink.switchId, {
|
||||
port_number: Number(swLink.portNumber),
|
||||
port_type: 'RJ45',
|
||||
});
|
||||
connectedToPortId = newSwPort.id;
|
||||
}
|
||||
} else {
|
||||
connectedToPortId = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (portModal?.editPort) {
|
||||
await api.updatePort(componentId, portModal.editPort.id, {
|
||||
label: portForm.label || undefined,
|
||||
port_type: portForm.port_type,
|
||||
notes: portForm.notes || undefined,
|
||||
connected_to_port_id: connectedToPortId,
|
||||
});
|
||||
} else {
|
||||
await api.createPort(componentId, {
|
||||
port_number: Number(portForm.port_number),
|
||||
label: portForm.label || undefined,
|
||||
port_type: portForm.port_type,
|
||||
notes: portForm.notes || undefined,
|
||||
connected_to_port_id: connectedToPortId ?? undefined,
|
||||
});
|
||||
}
|
||||
setPortModal(null);
|
||||
setSwLink(null);
|
||||
setPortError(null);
|
||||
await loadComponent();
|
||||
};
|
||||
|
||||
const setField = (key: keyof Component, val: unknown) =>
|
||||
setForm(f => ({ ...f, [key]: val }));
|
||||
|
||||
if (loading || !component) return <div className="page-loading">Loading…</div>;
|
||||
|
||||
const meta = COMPONENT_META[component.type];
|
||||
const ports: Port[] = component.ports ?? [];
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<div className="breadcrumb">
|
||||
<Link to="/">Dashboard</Link>
|
||||
{component.site && <><span> / </span><Link to={`/sites/${component.site.id}`}>{component.site.name}</Link></>}
|
||||
{component.room && <><span> / </span><Link to={`/rooms/${component.room.id}`}>{component.room.name}</Link></>}
|
||||
{component.rack && <><span> / </span><Link to={`/racks/${component.rack.id}`}>{component.rack.name}</Link></>}
|
||||
<span> / </span>
|
||||
<span>{component.name}</span>
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
{component.rack && (
|
||||
<button className="btn-back btn-sm" onClick={() => navigate(`/racks/${component.rack!.id}`)}>← Back to Rack</button>
|
||||
)}
|
||||
<button className="btn-secondary btn-sm" onClick={() => setEditing(v => !v)}>
|
||||
{editing ? 'Cancel' : '✎ Edit'}
|
||||
</button>
|
||||
<button className="btn-danger btn-sm" onClick={handleDelete}>🗑 Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Component Header */}
|
||||
<div className="component-header" style={{ borderLeft: `6px solid ${meta.color}` }}>
|
||||
<div className="component-type-badge" style={{ background: meta.bg, color: meta.color }}>
|
||||
{meta.label}
|
||||
</div>
|
||||
<h1 className="entity-title">{component.name}</h1>
|
||||
<span className={`status-badge status-${component.status}`}>{component.status}</span>
|
||||
</div>
|
||||
|
||||
{/* Meta fields */}
|
||||
<section className="content-section">
|
||||
{editing ? (
|
||||
<div className="edit-meta-form">
|
||||
<div className="form-row">
|
||||
<div className="form-group form-group-lg">
|
||||
<label>Name</label>
|
||||
<input className="form-input" value={form.name ?? ''} onChange={e => setField('name', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Type</label>
|
||||
<select className="form-input" value={form.type ?? 'other'} onChange={e => setField('type', e.target.value)}>
|
||||
{COMPONENT_TYPES.map(t => <option key={t} value={t}>{COMPONENT_META[t].label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Status</label>
|
||||
<select className="form-input" value={form.status ?? 'active'} onChange={e => setField('status', e.target.value)}>
|
||||
{STATUS_OPTIONS.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Position (U)</label>
|
||||
<input type="number" className="form-input" value={form.position ?? ''} onChange={e => setField('position', e.target.value ? Number(e.target.value) : null)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Height (U)</label>
|
||||
<input type="number" className="form-input" value={form.height_units ?? 1} onChange={e => setField('height_units', Number(e.target.value))} />
|
||||
</div>
|
||||
{(form.type === 'switch' || form.type === 'patch_panel') && (
|
||||
<div className="form-group">
|
||||
<label>{form.type === 'patch_panel' ? 'Available Patches' : 'Switch Ports'}</label>
|
||||
<input
|
||||
type="number" min={1} max={96} className="form-input"
|
||||
value={form.port_count ?? 24}
|
||||
onChange={e => setField('port_count', Number(e.target.value))}
|
||||
/>
|
||||
<span className="form-hint">
|
||||
{form.type === 'patch_panel' ? 'RJ45 jacks on front panel' : 'Ethernet port count'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{form.type === 'switch' && (
|
||||
<div className="form-group">
|
||||
<label>Fiber / SFP Ports</label>
|
||||
<input
|
||||
type="number" min={0} max={16} className="form-input"
|
||||
value={form.sfp_count ?? 0}
|
||||
onChange={e => setField('sfp_count', Number(e.target.value))}
|
||||
/>
|
||||
<span className="form-hint">SFP / fiber uplink slots</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Manufacturer</label>
|
||||
<input className="form-input" value={form.manufacturer ?? ''} onChange={e => setField('manufacturer', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Model</label>
|
||||
<input className="form-input" value={form.model ?? ''} onChange={e => setField('model', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>IP Address</label>
|
||||
<input className="form-input" value={form.ip_address ?? ''} onChange={e => setField('ip_address', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>MAC Address</label>
|
||||
<input className="form-input" value={form.mac_address ?? ''} onChange={e => setField('mac_address', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Serial Number</label>
|
||||
<input className="form-input" value={form.serial_number ?? ''} onChange={e => setField('serial_number', e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Asset Tag</label>
|
||||
<input className="form-input" value={form.asset_tag ?? ''} onChange={e => setField('asset_tag', e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button className="btn-primary btn-sm" onClick={saveMeta} disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save Changes'}
|
||||
</button>
|
||||
<button className="btn-secondary btn-sm" onClick={() => setEditing(false)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="component-meta-grid">
|
||||
<MetaField label="Type" value={meta.label} />
|
||||
<MetaField label="Status" value={component.status} />
|
||||
<MetaField label="Rack Position" value={component.position != null ? `${component.position}U` : '—'} />
|
||||
<MetaField label="Height" value={`${component.height_units}U`} />
|
||||
<MetaField label="Manufacturer" value={component.manufacturer} />
|
||||
<MetaField label="Model" value={component.model} />
|
||||
<MetaField label="IP Address" value={component.ip_address} />
|
||||
<MetaField label="MAC Address" value={component.mac_address} />
|
||||
<MetaField label="Serial Number" value={component.serial_number} />
|
||||
<MetaField label="Asset Tag" value={component.asset_tag} />
|
||||
{(component.type === 'switch' || component.type === 'patch_panel') && (
|
||||
<MetaField
|
||||
label={component.type === 'patch_panel' ? 'Available Patches' : 'Port Count'}
|
||||
value={component.port_count != null ? `${component.port_count} ports` : null}
|
||||
/>
|
||||
)}
|
||||
{component.type === 'switch' && (
|
||||
<MetaField
|
||||
label="Fiber / SFP Ports"
|
||||
value={component.sfp_count != null ? `${component.sfp_count} SFP slots` : null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Ports */}
|
||||
<section className="content-section">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">Ports / Connections</h2>
|
||||
<button className="btn-primary btn-sm" onClick={() => openPortModal()}>
|
||||
+ {component.type === 'patch_panel' ? 'Add PP Port' : 'Add SW Port'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{ports.length === 0 ? (
|
||||
<div className="empty-state-sm">No ports documented yet.</div>
|
||||
) : (
|
||||
<div className="ports-grid">
|
||||
{ports.map(port => (
|
||||
<div key={port.id} className="port-item">
|
||||
<div className="port-number">{port.port_number}</div>
|
||||
<div className="port-info">
|
||||
<div className="port-label">{port.label || '—'}</div>
|
||||
{component.type !== 'patch_panel' && (
|
||||
<div className="port-type">{port.port_type}</div>
|
||||
)}
|
||||
{port.notes && <div className="port-notes">{port.notes}</div>}
|
||||
{port.linked_port && (
|
||||
<div className="port-link-chain">
|
||||
{component.type === 'switch'
|
||||
? `↔ ${port.linked_port.component_name} : P${port.linked_port.port_number}${port.linked_port.label ? ` → ${port.linked_port.label}` : ''}`
|
||||
: `↔ ${port.linked_port.component_name} : P${port.linked_port.port_number}`
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button className="port-edit-btn" onClick={() => openPortModal(port)} title="Edit port">✎</button>
|
||||
<button className="port-delete" onClick={() => handleDeletePort(port.id)} title="Delete port">✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Port Modal */}
|
||||
{portModal !== null && (
|
||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && closePortModal()}>
|
||||
<div className="modal">
|
||||
<div className="modal-header">
|
||||
<span className="modal-title">
|
||||
{portModal.editPort
|
||||
? `Edit ${component.type === 'patch_panel' ? 'PP' : 'SW'} Port ${portModal.editPort.port_number}`
|
||||
: `Add ${component.type === 'patch_panel' ? 'PP Port' : 'SW Port'}`}
|
||||
</span>
|
||||
<button className="modal-close" onClick={closePortModal}>✕</button>
|
||||
</div>
|
||||
<form onSubmit={handleSavePort} style={{ padding: '20px', display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>{component.type === 'patch_panel' ? 'PP Port #' : 'SW Port #'}</label>
|
||||
<input
|
||||
type="number" className="form-input"
|
||||
min={1}
|
||||
max={component.type === 'patch_panel' && component.port_count ? component.port_count : undefined}
|
||||
value={portForm.port_number}
|
||||
onChange={e => setPortForm(f => ({ ...f, port_number: e.target.value }))}
|
||||
required
|
||||
disabled={!!portModal.editPort}
|
||||
/>
|
||||
{component.type === 'patch_panel' && component.port_count && !portModal.editPort && (
|
||||
<span className="form-hint">Port 1–{component.port_count}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="form-group form-group-lg">
|
||||
<label>{component.type === 'patch_panel' ? 'End Device' : 'Label'}</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={portForm.label}
|
||||
onChange={e => setPortForm(f => ({ ...f, label: e.target.value }))}
|
||||
placeholder={component.type === 'patch_panel' ? 'e.g. Desktop 1, Printer, IP Camera' : 'e.g. Uplink-01'}
|
||||
autoFocus={!portModal.editPort}
|
||||
/>
|
||||
</div>
|
||||
{component.type !== 'patch_panel' && (
|
||||
<div className="form-group">
|
||||
<label>Type</label>
|
||||
<select className="form-input" value={portForm.port_type}
|
||||
onChange={e => setPortForm(f => ({ ...f, port_type: e.target.value }))}>
|
||||
{['RJ45', 'SFP', 'SFP+', 'QSFP', 'LC', 'SC', 'Serial', 'USB', 'HDMI'].map(t =>
|
||||
<option key={t} value={t}>{t}</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="form-group">
|
||||
<label>Notes</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={portForm.notes}
|
||||
onChange={e => setPortForm(f => ({ ...f, notes: e.target.value }))}
|
||||
placeholder={component.type === 'patch_panel' ? 'e.g. Room A, Floor 2' : 'e.g. VLAN 10'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{component.type === 'switch' && patchPanels.length > 0 && (
|
||||
<PpLinkPicker
|
||||
key={portModal.editPort?.id ?? 'add'}
|
||||
patchPanels={patchPanels}
|
||||
initialPpId={
|
||||
portModal.editPort?.linked_port?.component_type === 'patch_panel'
|
||||
? portModal.editPort.linked_port.component_id
|
||||
: undefined
|
||||
}
|
||||
value={portForm.connected_to_port_id}
|
||||
onChange={id => setPortForm(f => ({ ...f, connected_to_port_id: id }))}
|
||||
/>
|
||||
)}
|
||||
{component.type === 'patch_panel' && (
|
||||
<SwLinkPicker
|
||||
key={portModal.editPort?.id ?? 'add'}
|
||||
switches={switches}
|
||||
initialSwitchId={swLink?.switchId}
|
||||
initialPortNumber={swLink?.portNumber}
|
||||
currentLinkedPortId={swLink?.portId}
|
||||
onChange={setSwLink}
|
||||
/>
|
||||
)}
|
||||
{portError && (
|
||||
<div style={{ color: '#f87171', fontSize: 13, background: '#450a0a', border: '1px solid #7f1d1d', borderRadius: 6, padding: '8px 12px' }}>
|
||||
⚠ {portError}
|
||||
</div>
|
||||
)}
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="btn-primary btn-sm">
|
||||
{portModal.editPort ? 'Save Changes' : (component.type === 'patch_panel' ? 'Add PP Port' : 'Add SW Port')}
|
||||
</button>
|
||||
<button type="button" className="btn-secondary btn-sm" onClick={closePortModal}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
<section className="content-section">
|
||||
<h2 className="section-title">Documentation</h2>
|
||||
<MarkdownEditor value={notes} onChange={setNotes} onSave={saveNotes} />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaField({ label, value }: { label: string; value?: string | number | null }) {
|
||||
if (!value && value !== 0) return null;
|
||||
return (
|
||||
<div className="meta-field">
|
||||
<span className="meta-field-label">{label}</span>
|
||||
<span className="meta-field-value">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PpLinkPicker({
|
||||
patchPanels,
|
||||
value,
|
||||
initialPpId,
|
||||
onChange,
|
||||
}: {
|
||||
patchPanels: Component[];
|
||||
value: string;
|
||||
initialPpId?: string;
|
||||
onChange: (portId: string) => void;
|
||||
}) {
|
||||
const [selectedPpId, setSelectedPpId] = useState(initialPpId ?? '');
|
||||
const [ppPorts, setPpPorts] = useState<Port[]>([]);
|
||||
const [loadingPp, setLoadingPp] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPpId) { setPpPorts([]); return; }
|
||||
setLoadingPp(true);
|
||||
api.getComponent(selectedPpId).then(pp => {
|
||||
setPpPorts(pp.ports ?? []);
|
||||
setLoadingPp(false);
|
||||
});
|
||||
}, [selectedPpId]);
|
||||
|
||||
const selectedPort = ppPorts.find(p => p.id === value);
|
||||
|
||||
return (
|
||||
<div className="pp-link-picker">
|
||||
<span className="pp-link-label">🔗 Link to Patch Panel</span>
|
||||
<div className="pp-link-row">
|
||||
<select
|
||||
className="form-input form-input-sm"
|
||||
value={selectedPpId}
|
||||
onChange={e => { setSelectedPpId(e.target.value); onChange(''); }}
|
||||
>
|
||||
<option value="">— no patch panel —</option>
|
||||
{patchPanels.map(pp => (
|
||||
<option key={pp.id} value={pp.id}>{pp.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedPpId && (
|
||||
<select
|
||||
className="form-input form-input-sm"
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
disabled={loadingPp}
|
||||
>
|
||||
<option value="">— select port —</option>
|
||||
{ppPorts.map(p => (
|
||||
<option key={p.id} value={p.id}>
|
||||
Port {p.port_number}{p.label ? ` — ${p.label}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
{selectedPort?.label && (
|
||||
<div className="pp-link-device-hint">
|
||||
↳ End device: <strong>{selectedPort.label}</strong>
|
||||
</div>
|
||||
)}
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
className="pp-link-clear"
|
||||
onClick={() => { onChange(''); setSelectedPpId(''); }}
|
||||
>
|
||||
✕ Clear link
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SwLinkPicker({
|
||||
switches,
|
||||
initialSwitchId,
|
||||
initialPortNumber,
|
||||
currentLinkedPortId,
|
||||
onChange,
|
||||
}: {
|
||||
switches: Component[];
|
||||
initialSwitchId?: string;
|
||||
initialPortNumber?: string;
|
||||
currentLinkedPortId?: string | null;
|
||||
onChange: (link: { switchId: string; portNumber: string; portId: string | null } | null) => void;
|
||||
}) {
|
||||
const [selectedSwitchId, setSelectedSwitchId] = useState(initialSwitchId ?? '');
|
||||
const [switchData, setSwitchData] = useState<Component | null>(null);
|
||||
const [loadingSwitch, setLoadingSwitch] = useState(false);
|
||||
const [selectedPortNumber, setSelectedPortNumber] = useState(initialPortNumber ?? '');
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSwitchId) { setSwitchData(null); return; }
|
||||
setLoadingSwitch(true);
|
||||
api.getComponent(selectedSwitchId).then(sw => {
|
||||
setSwitchData(sw);
|
||||
setLoadingSwitch(false);
|
||||
});
|
||||
}, [selectedSwitchId]);
|
||||
|
||||
const portCount = switchData?.port_count ?? 24;
|
||||
|
||||
const handleSwitchChange = (swId: string) => {
|
||||
setSelectedSwitchId(swId);
|
||||
setSelectedPortNumber('');
|
||||
onChange(null);
|
||||
};
|
||||
|
||||
const handlePortChange = (portNumberStr: string) => {
|
||||
setSelectedPortNumber(portNumberStr);
|
||||
if (!portNumberStr || !selectedSwitchId) { onChange(null); return; }
|
||||
const existingPort = (switchData?.ports ?? []).find(p => p.port_number === Number(portNumberStr));
|
||||
onChange({ switchId: selectedSwitchId, portNumber: portNumberStr, portId: existingPort?.id ?? null });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pp-link-picker">
|
||||
<span className="pp-link-label">🔗 Link to Switch Port <span style={{ color: 'var(--text3)', fontWeight: 400 }}>(optional)</span></span>
|
||||
<div className="pp-link-row">
|
||||
<select
|
||||
className="form-input form-input-sm"
|
||||
value={selectedSwitchId}
|
||||
onChange={e => handleSwitchChange(e.target.value)}
|
||||
>
|
||||
<option value="">— no switch —</option>
|
||||
{switches.map(sw => (
|
||||
<option key={sw.id} value={sw.id}>{sw.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedSwitchId && !loadingSwitch && switchData && (
|
||||
<select
|
||||
className="form-input form-input-sm"
|
||||
value={selectedPortNumber}
|
||||
onChange={e => handlePortChange(e.target.value)}
|
||||
>
|
||||
<option value="">— select port —</option>
|
||||
{Array.from({ length: portCount }, (_, i) => i + 1).map(n => {
|
||||
const existingPort = (switchData.ports ?? []).find(p => p.port_number === n);
|
||||
const isOccupied = existingPort?.connected_to_port_id && existingPort.id !== currentLinkedPortId;
|
||||
return (
|
||||
<option key={n} value={String(n)} disabled={!!isOccupied}>
|
||||
Port {n}{existingPort?.label ? ` — ${existingPort.label}` : ''}{isOccupied ? ' (in use)' : ''}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
)}
|
||||
{loadingSwitch && <span style={{ color: 'var(--text3)', fontSize: 12 }}>Loading…</span>}
|
||||
{switches.length === 0 && (
|
||||
<span style={{ color: 'var(--text3)', fontSize: 12 }}>No switches in this rack</span>
|
||||
)}
|
||||
</div>
|
||||
{selectedSwitchId && selectedPortNumber && (
|
||||
<button
|
||||
type="button"
|
||||
className="pp-link-clear"
|
||||
onClick={() => { setSelectedSwitchId(''); setSelectedPortNumber(''); onChange(null); }}
|
||||
>
|
||||
✕ Clear link
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { COMPONENT_META } from '../types';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface GNode {
|
||||
id: string;
|
||||
label: string;
|
||||
type: string;
|
||||
url: string;
|
||||
x: number; y: number;
|
||||
vx: number; vy: number;
|
||||
tx: number; ty: number;
|
||||
pinned: boolean;
|
||||
}
|
||||
|
||||
interface GEdge {
|
||||
source: string;
|
||||
target: string;
|
||||
type: 'hierarchy' | 'connection';
|
||||
}
|
||||
|
||||
type VB = { x: number; y: number; w: number; h: number };
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const HOME_K = 0.14;
|
||||
const REPULSION = 900;
|
||||
const DAMPING = 0.78;
|
||||
const JITTER = 0.18;
|
||||
const V_GAP = 170;
|
||||
|
||||
const NODE_R: Record<string, number> = { site: 36, room: 30, rack: 27, port: 12 };
|
||||
const DEFAULT_R = 22;
|
||||
|
||||
const ICONS: Record<string, string> = {
|
||||
site: '🏢', room: '🚪', rack: '🗄️',
|
||||
server: '💻', switch: '🔀', router: '🌐',
|
||||
firewall: '🛡️', patch_panel: '🔌', ups: '🔋',
|
||||
pdu: '⚡', kvm: '🖥️', storage: '💾', other: '📦',
|
||||
port: '◉',
|
||||
};
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
site: 'Site', room: 'Room', rack: 'Rack',
|
||||
server: 'Server', switch: 'Switch', router: 'Router',
|
||||
firewall: 'Firewall', patch_panel: 'Patch Panel', ups: 'UPS',
|
||||
pdu: 'PDU', kvm: 'KVM', storage: 'Storage', other: 'Other',
|
||||
port: 'Port',
|
||||
};
|
||||
|
||||
function nodeColors(type: string) {
|
||||
if (type === 'site') return { fill: '#1c2d1e', stroke: '#3fb950', text: '#3fb950' };
|
||||
if (type === 'room') return { fill: '#1a2744', stroke: '#58a6ff', text: '#58a6ff' };
|
||||
if (type === 'rack') return { fill: '#21262d', stroke: '#8b949e', text: '#8b949e' };
|
||||
if (type === 'port') return { fill: '#1a1f2e', stroke: '#f59e0b', text: '#f59e0b' };
|
||||
const m = COMPONENT_META[type as keyof typeof COMPONENT_META];
|
||||
return m
|
||||
? { fill: m.bg, stroke: m.color, text: m.color }
|
||||
: { fill: '#21262d', stroke: '#6e7681', text: '#6e7681' };
|
||||
}
|
||||
|
||||
// ── Tree layout (BFS top-down) ───────────────────────────────────────────────
|
||||
|
||||
function computeTreeLayout(
|
||||
nodes: { id: string }[],
|
||||
edges: GEdge[],
|
||||
): Map<string, { tx: number; ty: number }> {
|
||||
if (nodes.length === 0) return new Map();
|
||||
|
||||
const hierEdges = edges.filter(e => e.type === 'hierarchy');
|
||||
const children: Record<string, string[]> = {};
|
||||
const hasParent = new Set<string>();
|
||||
for (const e of hierEdges) {
|
||||
(children[e.source] ??= []).push(e.target);
|
||||
hasParent.add(e.target);
|
||||
}
|
||||
|
||||
const root = nodes.find(n => !hasParent.has(n.id))?.id ?? nodes[0].id;
|
||||
|
||||
const depth: Record<string, number> = { [root]: 0 };
|
||||
const queue = [root];
|
||||
while (queue.length) {
|
||||
const cur = queue.shift()!;
|
||||
for (const child of (children[cur] ?? [])) {
|
||||
if (depth[child] == null) {
|
||||
depth[child] = depth[cur] + 1;
|
||||
queue.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const byLevel: Record<number, string[]> = {};
|
||||
for (const n of nodes) {
|
||||
const lv = depth[n.id] ?? 0;
|
||||
(byLevel[lv] ??= []).push(n.id);
|
||||
}
|
||||
|
||||
const sortedByLevel: Record<number, string[]> = Object.fromEntries(
|
||||
Object.entries(byLevel).map(([lv, ids]) => [
|
||||
lv,
|
||||
ids.slice().sort((a, b) => {
|
||||
const pa = hierEdges.find(e => e.target === a)?.source;
|
||||
const pb = hierEdges.find(e => e.target === b)?.source;
|
||||
if (!pa || !pb || pa === pb) return 0;
|
||||
const lpa = depth[pa] ?? 0;
|
||||
const levelNodes = byLevel[lpa] ?? [];
|
||||
return levelNodes.indexOf(pa) - levelNodes.indexOf(pb);
|
||||
}),
|
||||
])
|
||||
);
|
||||
|
||||
const maxLevel = Math.max(0, ...Object.keys(sortedByLevel).map(Number));
|
||||
const positions = new Map<string, { tx: number; ty: number }>();
|
||||
|
||||
for (const [lvStr, ids] of Object.entries(sortedByLevel)) {
|
||||
const lv = Number(lvStr);
|
||||
const count = ids.length;
|
||||
const spacing = count === 1 ? 0 : Math.max(115, 800 / count);
|
||||
const totalW = (count - 1) * spacing;
|
||||
const baseY = lv * V_GAP - (maxLevel * V_GAP) / 2;
|
||||
ids.forEach((nid, i) => {
|
||||
positions.set(nid, {
|
||||
tx: -totalW / 2 + i * spacing,
|
||||
ty: baseY,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function GraphPage() {
|
||||
const { level, id } = useParams<{ level: string; id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [errMsg, setErrMsg] = useState<string | null>(null);
|
||||
const [pageTitle, setPageTitle] = useState('Graph View');
|
||||
const [renderNodes, setRenderNodes] = useState<GNode[]>([]);
|
||||
const [edges, setEdges] = useState<GEdge[]>([]);
|
||||
|
||||
const nodesRef = useRef<GNode[]>([]);
|
||||
const edgesRef = useRef<GEdge[]>([]);
|
||||
const rafRef = useRef<number>(0);
|
||||
const alphaRef = useRef(0.5);
|
||||
const hasMoved = useRef(false);
|
||||
const dragging = useRef<{ id: string; ox: number; oy: number } | null>(null);
|
||||
const panning = useRef<{ x0: number; y0: number; vb0: VB } | null>(null);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const initVbRef = useRef<VB>({ x: -500, y: -350, w: 1000, h: 700 });
|
||||
const [vb, setVb] = useState<VB>(initVbRef.current);
|
||||
|
||||
useEffect(() => {
|
||||
if (!level || !id) return;
|
||||
setLoading(true);
|
||||
setErrMsg(null);
|
||||
fetch(`/api/graph/${level}/${id}`)
|
||||
.then(r => r.ok ? r.json() : r.json().then((b: { error: string }) => Promise.reject(b.error)))
|
||||
.then((data: { nodes: Omit<GNode, 'x' | 'y' | 'vx' | 'vy' | 'tx' | 'ty' | 'pinned'>[]; edges: GEdge[] }) => {
|
||||
const positions = computeTreeLayout(data.nodes, data.edges);
|
||||
const placed: GNode[] = data.nodes.map(node => {
|
||||
const pos = positions.get(node.id) ?? { tx: 0, ty: 0 };
|
||||
return { ...node, x: pos.tx, y: pos.ty, vx: 0, vy: 0, tx: pos.tx, ty: pos.ty, pinned: false };
|
||||
});
|
||||
nodesRef.current = placed;
|
||||
edgesRef.current = data.edges;
|
||||
setEdges(data.edges);
|
||||
setRenderNodes([...placed]);
|
||||
alphaRef.current = 0.45;
|
||||
|
||||
if (data.nodes.length > 0) {
|
||||
const root = data.nodes[0];
|
||||
setPageTitle(`${ICONS[root.type] ?? ''} ${root.label} — Graph`);
|
||||
}
|
||||
|
||||
if (placed.length > 0) {
|
||||
const xs = placed.map(n => n.x);
|
||||
const ys = placed.map(n => n.y);
|
||||
const pad = 100;
|
||||
const fitVb = {
|
||||
x: Math.min(...xs) - pad,
|
||||
y: Math.min(...ys) - pad,
|
||||
w: Math.max(Math.max(...xs) - Math.min(...xs) + pad * 2, 600),
|
||||
h: Math.max(Math.max(...ys) - Math.min(...ys) + pad * 2, 400),
|
||||
};
|
||||
initVbRef.current = fitVb;
|
||||
setVb(fitVb);
|
||||
}
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((e: unknown) => { setErrMsg(String(e)); setLoading(false); });
|
||||
}, [level, id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading || nodesRef.current.length === 0) return;
|
||||
|
||||
const tick = () => {
|
||||
const ns = nodesRef.current;
|
||||
const a = alphaRef.current;
|
||||
|
||||
for (const n of ns) {
|
||||
if (n.pinned) continue;
|
||||
n.vx += (n.tx - n.x) * HOME_K * a;
|
||||
n.vy += (n.ty - n.y) * HOME_K * a;
|
||||
}
|
||||
|
||||
for (let i = 0; i < ns.length; i++) {
|
||||
for (let j = i + 1; j < ns.length; j++) {
|
||||
const dx = ns[j].x - ns[i].x || 0.01;
|
||||
const dy = ns[j].y - ns[i].y || 0.01;
|
||||
const d2 = dx * dx + dy * dy;
|
||||
if (d2 > 32000) continue;
|
||||
const d = Math.sqrt(d2);
|
||||
const f = (REPULSION * a) / d2;
|
||||
const fx = (dx / d) * f, fy = (dy / d) * f;
|
||||
ns[i].vx -= fx; ns[i].vy -= fy;
|
||||
ns[j].vx += fx; ns[j].vy += fy;
|
||||
}
|
||||
}
|
||||
|
||||
for (const n of ns) {
|
||||
if (n.pinned) { n.vx = 0; n.vy = 0; continue; }
|
||||
n.vx *= DAMPING;
|
||||
n.vy *= DAMPING;
|
||||
if (a <= 0.06) {
|
||||
n.vx += (Math.random() - 0.5) * JITTER;
|
||||
n.vy += (Math.random() - 0.5) * JITTER;
|
||||
}
|
||||
n.x += n.vx;
|
||||
n.y += n.vy;
|
||||
}
|
||||
|
||||
alphaRef.current = Math.max(a * 0.994, 0.05);
|
||||
setRenderNodes([...ns]);
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(rafRef.current);
|
||||
}, [loading, edges]);
|
||||
|
||||
const toSvg = useCallback((cx: number, cy: number) => {
|
||||
const svg = svgRef.current;
|
||||
if (!svg) return { x: cx, y: cy };
|
||||
const pt = svg.createSVGPoint();
|
||||
pt.x = cx; pt.y = cy;
|
||||
return pt.matrixTransform(svg.getScreenCTM()!.inverse());
|
||||
}, []);
|
||||
|
||||
const onNodeDown = useCallback((e: React.MouseEvent, nid: string) => {
|
||||
e.stopPropagation();
|
||||
hasMoved.current = false;
|
||||
const sp = toSvg(e.clientX, e.clientY);
|
||||
const node = nodesRef.current.find(n => n.id === nid);
|
||||
if (!node) return;
|
||||
dragging.current = { id: nid, ox: sp.x - node.x, oy: sp.y - node.y };
|
||||
nodesRef.current = nodesRef.current.map(n => n.id === nid ? { ...n, pinned: true } : n);
|
||||
alphaRef.current = Math.max(alphaRef.current, 0.25);
|
||||
}, [toSvg]);
|
||||
|
||||
const onBgDown = useCallback((e: React.MouseEvent) => {
|
||||
panning.current = { x0: e.clientX, y0: e.clientY, vb0: { ...vb } };
|
||||
}, [vb]);
|
||||
|
||||
const onMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
if (dragging.current) {
|
||||
hasMoved.current = true;
|
||||
const sp = toSvg(e.clientX, e.clientY);
|
||||
const { id: nid, ox, oy } = dragging.current;
|
||||
nodesRef.current = nodesRef.current.map(n =>
|
||||
n.id === nid ? { ...n, x: sp.x - ox, y: sp.y - oy } : n
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (panning.current) {
|
||||
const { x0, y0, vb0 } = panning.current;
|
||||
const scale = vb0.w / (svgRef.current?.clientWidth || 900);
|
||||
setVb(v => ({ ...v, x: vb0.x - (e.clientX - x0) * scale, y: vb0.y - (e.clientY - y0) * scale }));
|
||||
}
|
||||
}, [toSvg]);
|
||||
|
||||
const onMouseUp = useCallback(() => {
|
||||
if (dragging.current) {
|
||||
const nid = dragging.current.id;
|
||||
nodesRef.current = nodesRef.current.map(n =>
|
||||
n.id === nid ? { ...n, pinned: false, tx: n.x, ty: n.y } : n
|
||||
);
|
||||
dragging.current = null;
|
||||
alphaRef.current = Math.max(alphaRef.current, 0.15);
|
||||
}
|
||||
panning.current = null;
|
||||
}, []);
|
||||
|
||||
const onWheel = useCallback((e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
const sp = toSvg(e.clientX, e.clientY);
|
||||
const f = e.deltaY > 0 ? 1.12 : 0.89;
|
||||
setVb(v => ({
|
||||
x: sp.x - (sp.x - v.x) * f,
|
||||
y: sp.y - (sp.y - v.y) * f,
|
||||
w: v.w * f,
|
||||
h: v.h * f,
|
||||
}));
|
||||
}, [toSvg]);
|
||||
|
||||
if (loading) return <div className="page-loading">Building graph…</div>;
|
||||
if (errMsg) return <div className="page-loading" style={{ color: 'var(--danger)' }}>Error: {errMsg}</div>;
|
||||
|
||||
return (
|
||||
<div className="graph-page">
|
||||
<div className="graph-toolbar">
|
||||
<button className="btn-back btn-sm" onClick={() => navigate(-1)}>← Back</button>
|
||||
<span className="graph-title">{pageTitle}</span>
|
||||
<div className="graph-legend">
|
||||
<span className="graph-legend-item">
|
||||
<svg width="26" height="10" style={{ flexShrink: 0 }}>
|
||||
<line x1="0" y1="5" x2="26" y2="5" stroke="#444d58" strokeWidth="2.5" />
|
||||
</svg>
|
||||
Hierarchy
|
||||
</span>
|
||||
<span className="graph-legend-item">
|
||||
<svg width="26" height="10" style={{ flexShrink: 0 }}>
|
||||
<line x1="0" y1="5" x2="26" y2="5" stroke="#58a6ff" strokeWidth="2" strokeDasharray="5,3" />
|
||||
</svg>
|
||||
Port connection
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="btn-secondary btn-sm"
|
||||
style={{ marginLeft: 'auto' }}
|
||||
onClick={() => setVb(initVbRef.current)}
|
||||
>
|
||||
⊙ Fit View
|
||||
</button>
|
||||
<span className="graph-hint">Scroll = zoom · Drag node · Drag bg = pan · Click = open</span>
|
||||
</div>
|
||||
|
||||
<svg
|
||||
ref={svgRef}
|
||||
className="graph-canvas"
|
||||
viewBox={`${vb.x} ${vb.y} ${vb.w} ${vb.h}`}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseUp={onMouseUp}
|
||||
onMouseLeave={onMouseUp}
|
||||
onMouseDown={onBgDown}
|
||||
onWheel={onWheel}
|
||||
>
|
||||
{/* Level guide bands */}
|
||||
<g opacity="0.07">
|
||||
{Array.from(new Set(renderNodes.map(n => Math.round(n.ty)))).map(ty => (
|
||||
<line key={ty} x1={vb.x - 200} y1={ty} x2={vb.x + vb.w + 200} y2={ty}
|
||||
stroke="#8b949e" strokeWidth="40" />
|
||||
))}
|
||||
</g>
|
||||
|
||||
{/* Edges */}
|
||||
<g>
|
||||
{edges.map((e, i) => {
|
||||
const src = renderNodes.find(n => n.id === e.source);
|
||||
const tgt = renderNodes.find(n => n.id === e.target);
|
||||
if (!src || !tgt) return null;
|
||||
|
||||
if (e.type === 'connection') {
|
||||
const mx = (src.x + tgt.x) / 2;
|
||||
const my = (src.y + tgt.y) / 2 - 50;
|
||||
return (
|
||||
<path key={i}
|
||||
d={`M${src.x.toFixed(1)},${src.y.toFixed(1)} Q${mx.toFixed(1)},${my.toFixed(1)} ${tgt.x.toFixed(1)},${tgt.y.toFixed(1)}`}
|
||||
fill="none" stroke="#58a6ff" strokeWidth="1.5"
|
||||
strokeDasharray="5,3" strokeOpacity="0.8"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const midY = (src.y + tgt.y) / 2;
|
||||
return (
|
||||
<path key={i}
|
||||
d={`M${src.x.toFixed(1)},${src.y.toFixed(1)} L${src.x.toFixed(1)},${midY.toFixed(1)} L${tgt.x.toFixed(1)},${midY.toFixed(1)} L${tgt.x.toFixed(1)},${tgt.y.toFixed(1)}`}
|
||||
fill="none" stroke="#3d4650" strokeWidth="1.5" strokeOpacity="0.85"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
|
||||
{/* Nodes */}
|
||||
<g>
|
||||
{renderNodes.map(node => {
|
||||
const r = NODE_R[node.type] ?? DEFAULT_R;
|
||||
const c = nodeColors(node.type);
|
||||
const isStr = ['site', 'room', 'rack'].includes(node.type);
|
||||
const isPort = node.type === 'port';
|
||||
const label = node.label.length > 14 ? node.label.slice(0, 12) + '…' : node.label;
|
||||
|
||||
return (
|
||||
<g key={node.id} className="graph-node"
|
||||
transform={`translate(${node.x.toFixed(1)},${node.y.toFixed(1)})`}
|
||||
onMouseDown={ev => onNodeDown(ev, node.id)}
|
||||
onClick={ev => { ev.stopPropagation(); if (!hasMoved.current) navigate(node.url); }}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{isStr && (
|
||||
<circle r={r + 6} fill={c.fill} fillOpacity="0.3"
|
||||
stroke={c.stroke} strokeWidth="1" strokeOpacity="0.25" />
|
||||
)}
|
||||
<circle r={r} fill={c.fill} stroke={c.stroke} strokeWidth={isStr ? 2.5 : isPort ? 1 : 1.5} />
|
||||
{!isPort && (
|
||||
<text textAnchor="middle" dominantBaseline="central"
|
||||
fontSize={isStr ? 18 : 14}
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
>
|
||||
{ICONS[node.type] ?? '📦'}
|
||||
</text>
|
||||
)}
|
||||
<text y={r + (isPort ? 10 : 14)} textAnchor="middle" fontSize={isStr ? 12 : isPort ? 9 : 10}
|
||||
fill={c.text} fontWeight={isStr ? '600' : '400'}
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
{isStr && (
|
||||
<text y={r + 26} textAnchor="middle" fontSize={9} fill="#6e7681"
|
||||
style={{ pointerEvents: 'none', userSelect: 'none' }}
|
||||
>
|
||||
{TYPE_LABELS[node.type] ?? node.type}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{renderNodes.length === 0 && !loading && (
|
||||
<div className="graph-empty">
|
||||
Nothing to show yet — add rooms, racks and components first.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import * as api from '../api';
|
||||
import type { Site } from '../types';
|
||||
|
||||
export default function HomePage() {
|
||||
const [sites, setSites] = useState<Site[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api.getSites().then(s => { setSites(s); setLoading(false); });
|
||||
}, []);
|
||||
|
||||
if (loading) return <div className="page-loading">Loading…</div>;
|
||||
|
||||
if (sites.length === 0) {
|
||||
return (
|
||||
<div className="welcome-screen">
|
||||
<div className="welcome-icon">🗄️</div>
|
||||
<h1 className="welcome-title">Welcome to NetworkView</h1>
|
||||
<p className="welcome-sub">
|
||||
Document your home lab or enterprise network.<br />
|
||||
Create sites, add server rooms, build rack layouts, and keep Markdown notes on every device.
|
||||
</p>
|
||||
<div className="welcome-steps">
|
||||
<div className="step">
|
||||
<div className="step-num">1</div>
|
||||
<div>Click <strong>+</strong> next to "Sites" in the sidebar to create your first site.</div>
|
||||
</div>
|
||||
<div className="step">
|
||||
<div className="step-num">2</div>
|
||||
<div>Add a <strong>Room</strong> to the site (e.g. "Server Closet").</div>
|
||||
</div>
|
||||
<div className="step">
|
||||
<div className="step-num">3</div>
|
||||
<div>Create a <strong>Rack</strong> in the room and start adding devices.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1 className="page-title">Dashboard</h1>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-grid">
|
||||
{sites.map(site => (
|
||||
<Link key={site.id} to={`/sites/${site.id}`} className="dashboard-card">
|
||||
<div className="dashboard-card-icon">🏢</div>
|
||||
<div className="dashboard-card-body">
|
||||
<div className="dashboard-card-name">{site.name}</div>
|
||||
{site.location && <div className="dashboard-card-sub">{site.location}</div>}
|
||||
<div className="dashboard-card-meta">{site.room_count ?? 0} rooms</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import * as api from '../api';
|
||||
import type { Rack, Component } from '../types';
|
||||
import { COMPONENT_META } from '../types';
|
||||
import RackGraphicView from '../components/RackGraphicView';
|
||||
import MarkdownEditor from '../components/MarkdownEditor';
|
||||
import { AddComponentModal, SimpleCreateModal } from '../components/AddItemModal';
|
||||
import type { ComponentFormData } from '../components/AddItemModal';
|
||||
|
||||
// ── Rack Layout Table ─────────────────────────────────────────────────────────
|
||||
function RackLayoutView({ rack, components, onAddAtSlot }: {
|
||||
rack: Rack;
|
||||
components: Component[];
|
||||
onAddAtSlot?: (position: number) => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Map each unit number → component (for multi-U components, repeated)
|
||||
const byPos = new Map<number, Component>();
|
||||
for (const c of components) {
|
||||
if (c.position != null) {
|
||||
for (let u = c.position; u < c.position + c.height_units; u++) {
|
||||
byPos.set(u, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build rows for the table
|
||||
interface RowInfo { unit: number; comp?: Component; isFirst: boolean; span: number }
|
||||
const rows: RowInfo[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (let u = 1; u <= rack.total_units; u++) {
|
||||
const comp = byPos.get(u);
|
||||
if (comp) {
|
||||
if (!seen.has(comp.id)) {
|
||||
seen.add(comp.id);
|
||||
rows.push({ unit: u, comp, isFirst: true, span: comp.height_units });
|
||||
} else {
|
||||
rows.push({ unit: u, comp, isFirst: false, span: 0 });
|
||||
}
|
||||
} else {
|
||||
rows.push({ unit: u, comp: undefined, isFirst: true, span: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rack-layout">
|
||||
<table className="rack-layout-table">
|
||||
<tbody>
|
||||
{rows.map(row => (
|
||||
<tr key={row.unit} className="rack-layout-row">
|
||||
<td className="rack-layout-u">{row.unit}</td>
|
||||
{row.isFirst && (
|
||||
row.comp ? (
|
||||
<td
|
||||
rowSpan={row.span}
|
||||
className="rack-layout-comp"
|
||||
style={{
|
||||
background: COMPONENT_META[row.comp.type].bg,
|
||||
borderLeft: `4px solid ${COMPONENT_META[row.comp.type].color}`,
|
||||
}}
|
||||
onClick={() => navigate(`/components/${row.comp!.id}`)}
|
||||
>
|
||||
<span className="rack-layout-comp-name">{row.comp.name}</span>
|
||||
<span className="rack-layout-comp-type" style={{ color: COMPONENT_META[row.comp.type].color }}>
|
||||
{COMPONENT_META[row.comp.type].label}
|
||||
</span>
|
||||
{row.comp.model && <span className="rack-layout-comp-model">{row.comp.model}</span>}
|
||||
{row.comp.ip_address && <span className="rack-layout-comp-ip">{row.comp.ip_address}</span>}
|
||||
{row.span > 1 && <span className="rack-layout-comp-height">{row.span}U</span>}
|
||||
</td>
|
||||
) : (
|
||||
<td
|
||||
className="rack-layout-empty"
|
||||
onClick={() => onAddAtSlot?.(row.unit)}
|
||||
>
|
||||
<span className="rack-layout-empty-txt">· empty · click to add</span>
|
||||
</td>
|
||||
)
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RackPage() {
|
||||
const { rackId } = useParams<{ rackId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [rack, setRack] = useState<Rack | null>(null);
|
||||
const [components, setComponents] = useState<Component[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [showAddComponent, setShowAddComponent] = useState(false);
|
||||
const [addAtPosition, setAddAtPosition] = useState<number | undefined>(undefined);
|
||||
const [showEditRack, setShowEditRack] = useState(false);
|
||||
const [compView, setCompView] = useState<'table' | 'layout'>('table');
|
||||
|
||||
const loadRack = useCallback(async () => {
|
||||
if (!rackId) return;
|
||||
const data = await api.getRack(rackId);
|
||||
setRack(data);
|
||||
setComponents(data.components ?? []);
|
||||
setNotes(data.notes ?? '');
|
||||
setLoading(false);
|
||||
}, [rackId]);
|
||||
|
||||
useEffect(() => { loadRack(); }, [loadRack]);
|
||||
|
||||
const saveNotes = async (val: string) => {
|
||||
if (!rackId) return;
|
||||
await api.updateRack(rackId, { notes: val });
|
||||
};
|
||||
|
||||
const handleAddComponent = async (formData: ComponentFormData) => {
|
||||
await api.createComponent(formData);
|
||||
setShowAddComponent(false);
|
||||
await loadRack();
|
||||
};
|
||||
|
||||
const handleAddAtSlot = (position: number) => {
|
||||
setAddAtPosition(position);
|
||||
setShowAddComponent(true);
|
||||
};
|
||||
|
||||
const handleEditRack = async (data: Record<string, string | number>) => {
|
||||
if (!rackId) return;
|
||||
await api.updateRack(rackId, {
|
||||
name: data.name as string,
|
||||
total_units: Number(data.total_units),
|
||||
manufacturer: data.manufacturer as string,
|
||||
model: data.model as string,
|
||||
});
|
||||
setShowEditRack(false);
|
||||
await loadRack();
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!rackId || !rack || !confirm(`Delete rack "${rack.name}" and all its components?`)) return;
|
||||
await api.deleteRack(rackId);
|
||||
navigate(rack.room ? `/rooms/${rack.room.id}` : '/');
|
||||
};
|
||||
|
||||
if (loading || !rack) return <div className="page-loading">Loading…</div>;
|
||||
|
||||
const usedUnits = components.reduce((s, c) => s + (c.position != null ? c.height_units : 0), 0);
|
||||
const freeUnits = rack.total_units - usedUnits;
|
||||
|
||||
return (
|
||||
<div className="page page-rack">
|
||||
<div className="page-header">
|
||||
<div className="breadcrumb">
|
||||
<Link to="/">Dashboard</Link>
|
||||
{rack.site && <><span> / </span><Link to={`/sites/${rack.site.id}`}>{rack.site.name}</Link></>}
|
||||
{rack.room && <><span> / </span><Link to={`/rooms/${rack.room.id}`}>{rack.room.name}</Link></>}
|
||||
<span> / </span>
|
||||
<span>{rack.name}</span>
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
{rack.room && (
|
||||
<button className="btn-back btn-sm" onClick={() => navigate(`/rooms/${rack.room!.id}`)}>← Back to Room</button>
|
||||
)}
|
||||
<button className="btn-graph btn-sm" onClick={() => navigate(`/graph/rack/${rackId}`)}>⬡ Graph View</button>
|
||||
<button className="btn-secondary btn-sm" onClick={() => setShowEditRack(true)}>✎ Edit</button>
|
||||
<button className="btn-primary btn-sm" onClick={() => { setAddAtPosition(undefined); setShowAddComponent(true); }}>
|
||||
+ Add Component
|
||||
</button>
|
||||
<button className="btn-danger btn-sm" onClick={handleDelete}>🗑 Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="entity-header">
|
||||
<h1 className="entity-title">
|
||||
<span className="entity-icon">🗄️</span> {rack.name}
|
||||
</h1>
|
||||
<div className="entity-meta-row">
|
||||
<span className="entity-meta-chip">{rack.total_units}U total</span>
|
||||
<span className="entity-meta-chip" style={{ color: '#f59e0b' }}>{usedUnits}U used</span>
|
||||
<span className="entity-meta-chip" style={{ color: '#34d399' }}>{freeUnits}U free</span>
|
||||
{rack.manufacturer && <span className="entity-meta-chip">{rack.manufacturer}</span>}
|
||||
{rack.model && <span className="entity-meta-chip">{rack.model}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Two-column rack layout */}
|
||||
<div className="rack-page-columns">
|
||||
{/* LEFT: Component management */}
|
||||
<div className="rack-page-left">
|
||||
<section className="content-section">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">Components</h2>
|
||||
<div className="section-view-toggle">
|
||||
<button
|
||||
className={`btn-sm ${compView === 'table' ? 'btn-secondary' : 'btn-ghost'}`}
|
||||
title="Table view"
|
||||
onClick={() => setCompView('table')}
|
||||
>⊞ Table</button>
|
||||
<button
|
||||
className={`btn-sm ${compView === 'layout' ? 'btn-secondary' : 'btn-ghost'}`}
|
||||
title="Rack layout view"
|
||||
onClick={() => setCompView('layout')}
|
||||
>▦ Rack Map</button>
|
||||
</div>
|
||||
<button className="btn-primary btn-sm" onClick={() => { setAddAtPosition(undefined); setShowAddComponent(true); }}>
|
||||
+ Add Component
|
||||
</button>
|
||||
</div>
|
||||
{compView === 'layout' ? (
|
||||
components.length === 0 ? (
|
||||
<div className="empty-state"><p>No components yet.</p></div>
|
||||
) : (
|
||||
<RackLayoutView rack={rack} components={components} onAddAtSlot={handleAddAtSlot} />
|
||||
)
|
||||
) : (
|
||||
components.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No components yet.</p>
|
||||
<p style={{ fontSize: 12, color: 'var(--text3)', marginTop: 4 }}>
|
||||
Click <strong>+ Add Component</strong> or click an empty slot in the rack diagram →
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="component-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>U</th>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Model</th>
|
||||
<th>IP</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[...components]
|
||||
.sort((a, b) => (a.position ?? 9999) - (b.position ?? 9999))
|
||||
.map(c => {
|
||||
const meta = COMPONENT_META[c.type];
|
||||
return (
|
||||
<tr key={c.id} className="component-table-row" onClick={() => navigate(`/components/${c.id}`)} style={{ borderLeft: `3px solid ${meta.color}` }}>
|
||||
<td className="td-position">{c.position ?? '—'}</td>
|
||||
<td className="td-name">{c.name}</td>
|
||||
<td className="td-type" style={{ color: meta.color }}>{meta.label}</td>
|
||||
<td className="td-model">{c.model ?? '—'}</td>
|
||||
<td className="td-ip">{c.ip_address ?? '—'}</td>
|
||||
<td className="td-status"><span className={`status-badge status-${c.status}`}>{c.status}</span></td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="content-section">
|
||||
<h2 className="section-title">Rack Notes</h2>
|
||||
<MarkdownEditor value={notes} onChange={setNotes} onSave={saveNotes} />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* RIGHT: Graphical front panel */}
|
||||
<div className="rack-page-right">
|
||||
<div className="rack-page-right-scroll">
|
||||
<div className="rack-page-right-header">
|
||||
<span className="rack-page-right-title">Front Panel View</span>
|
||||
<span className="rack-page-right-hint">Click slot to add · Click device to open</span>
|
||||
</div>
|
||||
<RackGraphicView
|
||||
rack={rack}
|
||||
components={components}
|
||||
onAddAtSlot={handleAddAtSlot}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAddComponent && rackId && (
|
||||
<AddComponentModal
|
||||
rackId={rackId}
|
||||
totalUnits={rack.total_units}
|
||||
initialPosition={addAtPosition}
|
||||
onSave={handleAddComponent}
|
||||
onClose={() => setShowAddComponent(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showEditRack && (
|
||||
<SimpleCreateModal
|
||||
title="Edit Rack"
|
||||
fields={[
|
||||
{ key: 'name', label: 'Rack Name', defaultValue: rack.name },
|
||||
{ key: 'total_units', label: 'Size (U)', type: 'number', defaultValue: rack.total_units },
|
||||
{ key: 'manufacturer', label: 'Manufacturer', defaultValue: rack.manufacturer ?? '' },
|
||||
{ key: 'model', label: 'Model', defaultValue: rack.model ?? '' },
|
||||
]}
|
||||
onSave={handleEditRack}
|
||||
onClose={() => setShowEditRack(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import * as api from '../api';
|
||||
import type { Room, Rack } from '../types';
|
||||
import MarkdownEditor from '../components/MarkdownEditor';
|
||||
import { SimpleCreateModal } from '../components/AddItemModal';
|
||||
|
||||
export default function RoomPage() {
|
||||
const { roomId } = useParams<{ roomId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [room, setRoom] = useState<Room | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editName, setEditName] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [showAddRack, setShowAddRack] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!roomId) return;
|
||||
api.getRoom(roomId).then(r => {
|
||||
setRoom(r);
|
||||
setNotes(r.notes ?? '');
|
||||
setEditName(r.name);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [roomId]);
|
||||
|
||||
const saveNotes = async (val: string) => {
|
||||
if (!roomId) return;
|
||||
await api.updateRoom(roomId, { notes: val });
|
||||
};
|
||||
|
||||
const saveMeta = async () => {
|
||||
if (!roomId) return;
|
||||
await api.updateRoom(roomId, { name: editName });
|
||||
setRoom(r => r ? { ...r, name: editName } : r);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!roomId || !confirm(`Delete room "${room?.name}" and all its racks?`)) return;
|
||||
await api.deleteRoom(roomId);
|
||||
navigate(room?.site?.id ? `/sites/${room.site.id}` : '/');
|
||||
};
|
||||
|
||||
const handleAddRack = async (data: Record<string, string | number>) => {
|
||||
if (!roomId) return;
|
||||
const rack = await api.createRack({
|
||||
room_id: roomId,
|
||||
name: data.name as string,
|
||||
total_units: Number(data.total_units) || 42,
|
||||
});
|
||||
setShowAddRack(false);
|
||||
navigate(`/racks/${rack.id}`);
|
||||
};
|
||||
|
||||
if (loading || !room) return <div className="page-loading">Loading…</div>;
|
||||
|
||||
const racks = room.racks ?? [];
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<div className="breadcrumb">
|
||||
<Link to="/">Dashboard</Link>
|
||||
{room.site && <><span> / </span><Link to={`/sites/${room.site.id}`}>{room.site.name}</Link></>}
|
||||
<span> / </span>
|
||||
<span>{room.name}</span>
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
{room.site && (
|
||||
<button className="btn-back btn-sm" onClick={() => navigate(`/sites/${room.site!.id}`)}>← Back to Site</button>
|
||||
)}
|
||||
<button className="btn-graph btn-sm" onClick={() => navigate(`/graph/room/${roomId}`)}>⬡ Graph View</button>
|
||||
<button className="btn-secondary btn-sm" onClick={() => setEditing(v => !v)}>
|
||||
{editing ? 'Cancel' : '✎ Edit'}
|
||||
</button>
|
||||
<button className="btn-danger btn-sm" onClick={handleDelete}>🗑 Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editing ? (
|
||||
<div className="edit-meta-form">
|
||||
<div className="form-group">
|
||||
<label>Room Name</label>
|
||||
<input className="form-input" value={editName} onChange={e => setEditName(e.target.value)} />
|
||||
</div>
|
||||
<button className="btn-primary btn-sm" onClick={saveMeta}>Save</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="entity-header">
|
||||
<h1 className="entity-title">
|
||||
<span className="entity-icon">🚪</span> {room.name}
|
||||
</h1>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Racks */}
|
||||
<section className="content-section">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">Racks</h2>
|
||||
<button className="btn-primary btn-sm" onClick={() => setShowAddRack(true)}>+ Add Rack</button>
|
||||
</div>
|
||||
|
||||
{racks.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No racks yet. Add a rack to start building your layout.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="racks-grid">
|
||||
{racks.map((rack: Rack) => (
|
||||
<Link key={rack.id} to={`/racks/${rack.id}`} className="rack-card">
|
||||
<div className="rack-card-visual">
|
||||
{Array.from({ length: Math.min(rack.total_units, 8) }).map((_, i) => (
|
||||
<div key={i} className="rack-card-unit" />
|
||||
))}
|
||||
</div>
|
||||
<div className="rack-card-info">
|
||||
<div className="rack-card-name">{rack.name}</div>
|
||||
<div className="rack-card-meta">
|
||||
{rack.total_units}U
|
||||
{rack.manufacturer && ` · ${rack.manufacturer}`}
|
||||
{rack.model && ` ${rack.model}`}
|
||||
</div>
|
||||
<div className="rack-card-count">{rack.component_count ?? 0} devices</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="content-section">
|
||||
<h2 className="section-title">Notes</h2>
|
||||
<MarkdownEditor value={notes} onChange={setNotes} onSave={saveNotes} />
|
||||
</section>
|
||||
|
||||
{showAddRack && (
|
||||
<SimpleCreateModal
|
||||
title="Add Rack"
|
||||
fields={[
|
||||
{ key: 'name', label: 'Rack Name', placeholder: 'e.g. RACK-01' },
|
||||
{ key: 'total_units', label: 'Size (U)', type: 'number', defaultValue: 42, placeholder: '42' },
|
||||
]}
|
||||
onSave={handleAddRack}
|
||||
onClose={() => setShowAddRack(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import * as api from '../api';
|
||||
import type { AuditEntry, User, DbStats } from '../types';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const ACTION_COLORS: Record<string, string> = {
|
||||
create: '#3fb950',
|
||||
update: '#58a6ff',
|
||||
delete: '#f87171',
|
||||
delete_all: '#f87171',
|
||||
api_key_rotate: '#f59e0b',
|
||||
api_key_revoke: '#f59e0b',
|
||||
};
|
||||
|
||||
const ROLE_COLORS: Record<string, string> = {
|
||||
admin: '#f87171',
|
||||
editor: '#f59e0b',
|
||||
viewer: '#8b949e',
|
||||
};
|
||||
|
||||
function formatTs(ts: string) {
|
||||
return new Date(ts).toLocaleString();
|
||||
}
|
||||
|
||||
// ── Sub-section: DB Stats ─────────────────────────────────────────────────────
|
||||
function StatsPanel({ stats }: { stats: DbStats }) {
|
||||
return (
|
||||
<div className="settings-stats">
|
||||
{Object.entries(stats).map(([k, v]) => (
|
||||
<div key={k} className="settings-stat-card">
|
||||
<span className="settings-stat-val">{v}</span>
|
||||
<span className="settings-stat-key">{k.replace(/_/g, ' ')}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sub-section: Users ────────────────────────────────────────────────────────
|
||||
function UsersPanel() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAdd, setShowAdd] = useState(false);
|
||||
const [form, setForm] = useState({ username: '', email: '', role: 'viewer' });
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [apiKeyResult, setApiKeyResult] = useState<{ userId: string; key: string } | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setUsers(await api.getUsers());
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleAdd = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setErr(null);
|
||||
try {
|
||||
await api.createUser(form);
|
||||
setForm({ username: '', email: '', role: 'viewer' });
|
||||
setShowAdd(false);
|
||||
await load();
|
||||
} catch (ex: unknown) {
|
||||
setErr(ex instanceof Error ? ex.message : String(ex));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (u: User) => {
|
||||
if (!confirm(`Delete user "${u.username}"? This cannot be undone.`)) return;
|
||||
await api.deleteUser(u.id);
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleToggleActive = async (u: User) => {
|
||||
await api.updateUser(u.id, { is_active: !u.is_active });
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleRotateKey = async (u: User) => {
|
||||
if (!confirm(`Rotate API key for "${u.username}"? The old key will stop working immediately.`)) return;
|
||||
const result = await api.rotateApiKey(u.id);
|
||||
setApiKeyResult({ userId: u.id, key: result.api_key });
|
||||
};
|
||||
|
||||
const handleRevokeKey = async (u: User) => {
|
||||
if (!confirm(`Revoke API key for "${u.username}"?`)) return;
|
||||
await api.revokeApiKey(u.id);
|
||||
await load();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<h2 className="settings-section-title">Users</h2>
|
||||
<button className="btn-primary btn-sm" onClick={() => setShowAdd(v => !v)}>+ Add User</button>
|
||||
</div>
|
||||
<p className="settings-desc">
|
||||
User accounts for this NetworkView instance. Authentication is delegated to your central app —
|
||||
set <code>X-User-Id</code> + <code>X-Username</code> headers on API requests, or use per-user API keys.
|
||||
</p>
|
||||
|
||||
{showAdd && (
|
||||
<form className="settings-user-form" onSubmit={handleAdd}>
|
||||
<input
|
||||
className="modal-input" placeholder="Username *" required
|
||||
value={form.username} onChange={e => setForm(f => ({ ...f, username: e.target.value }))}
|
||||
/>
|
||||
<input
|
||||
className="modal-input" placeholder="Email" type="email"
|
||||
value={form.email} onChange={e => setForm(f => ({ ...f, email: e.target.value }))}
|
||||
/>
|
||||
<select
|
||||
className="modal-select"
|
||||
value={form.role} onChange={e => setForm(f => ({ ...f, role: e.target.value }))}
|
||||
>
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="editor">Editor</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
{err && <div className="settings-error">{err}</div>}
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="btn-primary btn-sm">Create</button>
|
||||
<button type="button" className="btn-secondary btn-sm" onClick={() => setShowAdd(false)}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{apiKeyResult && (
|
||||
<div className="settings-api-key-reveal">
|
||||
<strong>New API Key (copy now — shown only once):</strong>
|
||||
<code className="settings-api-key-code">{apiKeyResult.key}</code>
|
||||
<button className="btn-secondary btn-sm" onClick={() => { navigator.clipboard.writeText(apiKeyResult.key); }}>
|
||||
📋 Copy
|
||||
</button>
|
||||
<button className="btn-ghost btn-sm" onClick={() => setApiKeyResult(null)}>Dismiss</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? <div className="page-loading">Loading…</div> : (
|
||||
<table className="settings-user-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th><th>Email</th><th>Role</th><th>Status</th><th>API Key</th><th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(u => (
|
||||
<tr key={u.id} style={{ opacity: u.is_active ? 1 : 0.5 }}>
|
||||
<td><strong>{u.username}</strong></td>
|
||||
<td style={{ color: 'var(--text3)', fontSize: 12 }}>{u.email ?? '—'}</td>
|
||||
<td>
|
||||
<span className="status-badge" style={{ color: ROLE_COLORS[u.role] ?? '#8b949e', borderColor: ROLE_COLORS[u.role] ?? '#8b949e' }}>
|
||||
{u.role}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`status-badge status-${u.is_active ? 'active' : 'decommissioned'}`}>
|
||||
{u.is_active ? 'active' : 'inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontSize: 11, color: 'var(--text3)' }}>
|
||||
{u.has_api_key ? '●●●●●●●●' : '—'}
|
||||
</td>
|
||||
<td className="settings-user-actions">
|
||||
<button className="btn-ghost btn-sm" onClick={() => handleToggleActive(u)}
|
||||
title={u.is_active ? 'Deactivate' : 'Activate'}>
|
||||
{u.is_active ? '⏸' : '▶'}
|
||||
</button>
|
||||
<button className="btn-ghost btn-sm" onClick={() => handleRotateKey(u)} title="Rotate API key">🔑</button>
|
||||
{u.has_api_key && (
|
||||
<button className="btn-ghost btn-sm" onClick={() => handleRevokeKey(u)} title="Revoke API key">🚫</button>
|
||||
)}
|
||||
<button className="btn-danger btn-sm" onClick={() => handleDelete(u)}>🗑</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{users.length === 0 && (
|
||||
<tr><td colSpan={6} style={{ textAlign: 'center', color: 'var(--text3)', padding: 20 }}>No users yet</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sub-section: Audit Log ────────────────────────────────────────────────────
|
||||
function AuditPanel() {
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState({ entity_type: '', action: '' });
|
||||
const [offset, setOffset] = useState(0);
|
||||
const LIMIT = 50;
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
const result = await api.getAuditLog({ ...filter, limit: LIMIT, offset });
|
||||
setEntries(result.entries);
|
||||
setTotal(result.total);
|
||||
setLoading(false);
|
||||
}, [filter, offset]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleClearAudit = async () => {
|
||||
if (!confirm('Clear the entire audit log? This cannot be undone.')) return;
|
||||
await api.clearAuditLog();
|
||||
setOffset(0);
|
||||
await load();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<div className="settings-section-header">
|
||||
<h2 className="settings-section-title">Audit Log</h2>
|
||||
<button className="btn-danger btn-sm" onClick={handleClearAudit}>🗑 Clear Log</button>
|
||||
</div>
|
||||
|
||||
<div className="settings-audit-filters">
|
||||
<select className="modal-select" style={{ width: 'auto' }}
|
||||
value={filter.entity_type}
|
||||
onChange={e => { setFilter(f => ({ ...f, entity_type: e.target.value })); setOffset(0); }}>
|
||||
<option value="">All types</option>
|
||||
{['site','room','rack','component','port','user','database','audit_log'].map(t => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
<select className="modal-select" style={{ width: 'auto' }}
|
||||
value={filter.action}
|
||||
onChange={e => { setFilter(f => ({ ...f, action: e.target.value })); setOffset(0); }}>
|
||||
<option value="">All actions</option>
|
||||
{['create','update','delete','delete_all','api_key_rotate','api_key_revoke'].map(a => (
|
||||
<option key={a} value={a}>{a}</option>
|
||||
))}
|
||||
</select>
|
||||
<span style={{ color: 'var(--text3)', fontSize: 12 }}>{total} entries</span>
|
||||
</div>
|
||||
|
||||
{loading ? <div className="page-loading" style={{ height: 80 }}>Loading…</div> : (
|
||||
<>
|
||||
<table className="settings-audit-table">
|
||||
<thead>
|
||||
<tr><th>Time</th><th>User</th><th>Action</th><th>Type</th><th>Entity</th><th>Changes</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map(e => (
|
||||
<tr key={e.id}>
|
||||
<td style={{ whiteSpace: 'nowrap', fontSize: 11, color: 'var(--text3)' }}>{formatTs(e.created_at)}</td>
|
||||
<td style={{ fontSize: 12 }}>{e.username ?? '—'}</td>
|
||||
<td>
|
||||
<span className="audit-action-badge" style={{ color: ACTION_COLORS[e.action] ?? '#8b949e' }}>
|
||||
{e.action}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ fontSize: 12, color: 'var(--text3)' }}>{e.entity_type}</td>
|
||||
<td style={{ fontSize: 12 }}>{e.entity_name ?? e.entity_id ?? '—'}</td>
|
||||
<td style={{ fontSize: 11, color: 'var(--text3)', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{e.changes ?? '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{entries.length === 0 && (
|
||||
<tr><td colSpan={6} style={{ textAlign: 'center', color: 'var(--text3)', padding: 20 }}>No entries</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="settings-audit-pager">
|
||||
<button className="btn-secondary btn-sm" disabled={offset === 0}
|
||||
onClick={() => setOffset(o => Math.max(0, o - LIMIT))}>← Previous</button>
|
||||
<span style={{ color: 'var(--text3)', fontSize: 12 }}>
|
||||
{offset + 1}–{Math.min(offset + LIMIT, total)} of {total}
|
||||
</span>
|
||||
<button className="btn-secondary btn-sm" disabled={offset + LIMIT >= total}
|
||||
onClick={() => setOffset(o => o + LIMIT)}>Next →</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sub-section: Danger Zone ──────────────────────────────────────────────────
|
||||
function DangerZone({ onAction }: { onAction: () => void }) {
|
||||
const [confirmText, setConfirmText] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const deleteAll = async () => {
|
||||
if (confirmText !== 'DELETE ALL') {
|
||||
alert('Type DELETE ALL exactly to confirm.');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.deleteAllData();
|
||||
setConfirmText('');
|
||||
onAction();
|
||||
alert('All network data deleted.');
|
||||
} catch (e: unknown) {
|
||||
alert(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="settings-section settings-danger-zone">
|
||||
<h2 className="settings-section-title" style={{ color: '#f87171' }}>⚠ Danger Zone</h2>
|
||||
<p className="settings-desc">
|
||||
These operations are irreversible. All cascading data (rooms, racks, components, ports) will be permanently deleted.
|
||||
</p>
|
||||
|
||||
<div className="danger-action">
|
||||
<div>
|
||||
<strong>Delete all network data</strong>
|
||||
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '4px 0 0' }}>
|
||||
Removes all sites, rooms, racks, components, ports and audit entries. User accounts are kept.
|
||||
</p>
|
||||
</div>
|
||||
<div className="danger-confirm-row">
|
||||
<input
|
||||
className="modal-input danger-input"
|
||||
placeholder='Type "DELETE ALL" to confirm'
|
||||
value={confirmText}
|
||||
onChange={e => setConfirmText(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
className="btn-danger btn-sm"
|
||||
disabled={busy || confirmText !== 'DELETE ALL'}
|
||||
onClick={deleteAll}
|
||||
>
|
||||
{busy ? '…' : '🗑 Delete All'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Page ─────────────────────────────────────────────────────────────────
|
||||
type Tab = 'overview' | 'users' | 'audit' | 'danger';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [tab, setTab] = useState<Tab>('overview');
|
||||
const [stats, setStats] = useState<DbStats | null>(null);
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
setStats(await api.getDbStats());
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadStats(); }, [loadStats]);
|
||||
|
||||
const tabs: { key: Tab; label: string }[] = [
|
||||
{ key: 'overview', label: '📊 Overview' },
|
||||
{ key: 'users', label: '👤 Users' },
|
||||
{ key: 'audit', label: '📋 Audit Log' },
|
||||
{ key: 'danger', label: '⚠ Danger' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="page page-settings">
|
||||
<div className="page-header">
|
||||
<h1 className="entity-title" style={{ fontSize: 22 }}>⚙ Settings</h1>
|
||||
</div>
|
||||
|
||||
<div className="settings-tabs">
|
||||
{tabs.map(t => (
|
||||
<button
|
||||
key={t.key}
|
||||
className={`settings-tab ${tab === t.key ? 'settings-tab-active' : ''}`}
|
||||
onClick={() => setTab(t.key)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="settings-body">
|
||||
{tab === 'overview' && (
|
||||
<div className="settings-section">
|
||||
<h2 className="settings-section-title">Database Overview</h2>
|
||||
{stats ? <StatsPanel stats={stats} /> : <div className="page-loading">Loading…</div>}
|
||||
|
||||
<div className="settings-api-info">
|
||||
<h3 style={{ marginBottom: 8, color: 'var(--text2)' }}>API Integration</h3>
|
||||
<p className="settings-desc">
|
||||
This app exposes a REST API designed to be consumed by a central management app.
|
||||
Use the following headers on every request:
|
||||
</p>
|
||||
<table className="settings-api-table">
|
||||
<thead><tr><th>Header</th><th>Description</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><code>X-User-Id</code></td><td>UUID of the authenticated user (set by your auth gateway)</td></tr>
|
||||
<tr><td><code>X-Username</code></td><td>Display name of the authenticated user</td></tr>
|
||||
<tr><td><code>X-Api-Key</code></td><td>Per-user API key (alternative to gateway headers)</td></tr>
|
||||
<tr><td><code>X-Confirm: yes</code></td><td>Required for destructive operations</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="settings-desc" style={{ marginTop: 12 }}>
|
||||
<strong>Key endpoints:</strong>{' '}
|
||||
<code>GET /api/users</code> · <code>GET /api/audit</code> ·
|
||||
<code>GET /api/settings/stats</code> · <code>DELETE /api/settings/data/all</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'users' && <UsersPanel />}
|
||||
{tab === 'audit' && <AuditPanel />}
|
||||
{tab === 'danger' && <DangerZone onAction={loadStats} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import * as api from '../api';
|
||||
import type { Site, Room } from '../types';
|
||||
import MarkdownEditor from '../components/MarkdownEditor';
|
||||
import { SimpleCreateModal } from '../components/AddItemModal';
|
||||
|
||||
export default function SitePage() {
|
||||
const { siteId } = useParams<{ siteId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [site, setSite] = useState<Site & { rooms: Room[] } | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editName, setEditName] = useState('');
|
||||
const [editLocation, setEditLocation] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [showAddRoom, setShowAddRoom] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!siteId) return;
|
||||
api.getSite(siteId).then(s => {
|
||||
setSite(s);
|
||||
setNotes(s.notes ?? '');
|
||||
setEditName(s.name);
|
||||
setEditLocation(s.location ?? '');
|
||||
setLoading(false);
|
||||
});
|
||||
}, [siteId]);
|
||||
|
||||
const saveNotes = async (val: string) => {
|
||||
if (!siteId) return;
|
||||
await api.updateSite(siteId, { notes: val });
|
||||
};
|
||||
|
||||
const saveMeta = async () => {
|
||||
if (!siteId) return;
|
||||
await api.updateSite(siteId, { name: editName, location: editLocation });
|
||||
setSite(s => s ? { ...s, name: editName, location: editLocation } : s);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!siteId || !confirm(`Delete site "${site?.name}" and all its contents?`)) return;
|
||||
await api.deleteSite(siteId);
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const handleAddRoom = async (data: Record<string, string | number>) => {
|
||||
if (!siteId) return;
|
||||
const room = await api.createRoom({ site_id: siteId, name: data.name as string });
|
||||
setShowAddRoom(false);
|
||||
navigate(`/rooms/${room.id}`);
|
||||
};
|
||||
|
||||
if (loading || !site) return <div className="page-loading">Loading…</div>;
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<div className="breadcrumb">
|
||||
<Link to="/">Dashboard</Link>
|
||||
<span> / </span>
|
||||
<span>{site.name}</span>
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
<button className="btn-back btn-sm" onClick={() => navigate('/')}>← Dashboard</button>
|
||||
<button className="btn-graph btn-sm" onClick={() => navigate(`/graph/site/${siteId}`)}>⬡ Graph View</button>
|
||||
<button className="btn-secondary btn-sm" onClick={() => setEditing(v => !v)}>
|
||||
{editing ? 'Cancel' : '✎ Edit'}
|
||||
</button>
|
||||
<button className="btn-danger btn-sm" onClick={handleDelete}>🗑 Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editing ? (
|
||||
<div className="edit-meta-form">
|
||||
<div className="form-row">
|
||||
<div className="form-group form-group-lg">
|
||||
<label>Site Name</label>
|
||||
<input className="form-input" value={editName} onChange={e => setEditName(e.target.value)} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Location</label>
|
||||
<input className="form-input" value={editLocation} onChange={e => setEditLocation(e.target.value)} placeholder="e.g. Building A" />
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn-primary btn-sm" onClick={saveMeta}>Save</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="entity-header">
|
||||
<h1 className="entity-title">
|
||||
<span className="entity-icon">🏢</span> {site.name}
|
||||
</h1>
|
||||
{site.location && <div className="entity-location">📍 {site.location}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rooms list */}
|
||||
<section className="content-section">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">Rooms</h2>
|
||||
<button className="btn-primary btn-sm" onClick={() => setShowAddRoom(true)}>+ Add Room</button>
|
||||
</div>
|
||||
|
||||
{site.rooms.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<p>No rooms yet. Add a room to start documenting your racks.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="cards-grid">
|
||||
{site.rooms.map(room => (
|
||||
<Link key={room.id} to={`/rooms/${room.id}`} className="entity-card">
|
||||
<span className="entity-card-icon">🚪</span>
|
||||
<div className="entity-card-body">
|
||||
<div className="entity-card-name">{room.name}</div>
|
||||
<div className="entity-card-meta">{room.rack_count ?? 0} racks</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Notes */}
|
||||
<section className="content-section">
|
||||
<h2 className="section-title">Notes</h2>
|
||||
<MarkdownEditor value={notes} onChange={setNotes} onSave={saveNotes} />
|
||||
</section>
|
||||
|
||||
{showAddRoom && (
|
||||
<SimpleCreateModal
|
||||
title="Add Room"
|
||||
fields={[{ key: 'name', label: 'Room Name', placeholder: 'e.g. Server Room A' }]}
|
||||
onSave={handleAddRoom}
|
||||
onClose={() => setShowAddRoom(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
export type ComponentType =
|
||||
| 'server'
|
||||
| 'switch'
|
||||
| 'router'
|
||||
| 'firewall'
|
||||
| 'patch_panel'
|
||||
| 'ups'
|
||||
| 'pdu'
|
||||
| 'kvm'
|
||||
| 'storage'
|
||||
| 'other';
|
||||
|
||||
export type ComponentStatus = 'active' | 'maintenance' | 'decommissioned';
|
||||
|
||||
export interface Site {
|
||||
id: string;
|
||||
name: string;
|
||||
location?: string;
|
||||
notes: string;
|
||||
room_count?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Room {
|
||||
id: string;
|
||||
site_id: string;
|
||||
name: string;
|
||||
notes: string;
|
||||
rack_count?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
site?: { id: string; name: string };
|
||||
racks?: Rack[];
|
||||
}
|
||||
|
||||
export interface Rack {
|
||||
id: string;
|
||||
room_id?: string;
|
||||
name: string;
|
||||
total_units: number;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
notes: string;
|
||||
component_count?: number;
|
||||
components?: Component[];
|
||||
room?: { id: string; name: string; site_id: string };
|
||||
site?: { id: string; name: string };
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Component {
|
||||
id: string;
|
||||
rack_id?: string;
|
||||
name: string;
|
||||
type: ComponentType;
|
||||
position?: number | null;
|
||||
height_units: number;
|
||||
manufacturer?: string;
|
||||
model?: string;
|
||||
serial_number?: string;
|
||||
asset_tag?: string;
|
||||
ip_address?: string;
|
||||
mac_address?: string;
|
||||
status: ComponentStatus;
|
||||
notes: string;
|
||||
port_count?: number | null;
|
||||
sfp_count?: number | null;
|
||||
ports?: Port[];
|
||||
rack?: { id: string; name: string; total_units: number; room_id?: string };
|
||||
room?: { id: string; name: string; site_id: string };
|
||||
site?: { id: string; name: string };
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Port {
|
||||
id: string;
|
||||
component_id: string;
|
||||
port_number: number;
|
||||
label?: string;
|
||||
port_type: string;
|
||||
connected_to_port_id?: string | null;
|
||||
notes?: string;
|
||||
linked_port?: {
|
||||
id: string;
|
||||
port_number: number;
|
||||
label?: string;
|
||||
component_id: string;
|
||||
component_name: string;
|
||||
component_type: ComponentType;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface SearchResults {
|
||||
sites: Pick<Site, 'id' | 'name'>[];
|
||||
rooms: Pick<Room, 'id' | 'name' | 'site_id'>[];
|
||||
racks: Pick<Rack, 'id' | 'name' | 'room_id'>[];
|
||||
components: Pick<Component, 'id' | 'name' | 'type' | 'rack_id' | 'model' | 'ip_address'>[];
|
||||
}
|
||||
|
||||
export interface TreeNode {
|
||||
type: 'site' | 'room' | 'rack';
|
||||
id: string;
|
||||
name: string;
|
||||
children?: TreeNode[];
|
||||
expanded?: boolean;
|
||||
}
|
||||
|
||||
// Color & label metadata for component types
|
||||
export const COMPONENT_META: Record<ComponentType, { label: string; color: string; bg: string; border: string }> = {
|
||||
server: { label: 'Server', color: '#34d399', bg: '#064e3b', border: '#065f46' },
|
||||
switch: { label: 'Switch', color: '#60a5fa', bg: '#1e3a8a', border: '#1e40af' },
|
||||
router: { label: 'Router', color: '#c084fc', bg: '#3b0764', border: '#4c1d95' },
|
||||
firewall: { label: 'Firewall', color: '#f87171', bg: '#450a0a', border: '#7f1d1d' },
|
||||
patch_panel: { label: 'Patch Panel', color: '#d1d5db', bg: '#1f2937', border: '#374151' },
|
||||
ups: { label: 'UPS', color: '#fb923c', bg: '#451a03', border: '#7c2d12' },
|
||||
pdu: { label: 'PDU', color: '#fb7185', bg: '#500724', border: '#881337' },
|
||||
kvm: { label: 'KVM', color: '#2dd4bf', bg: '#042f2e', border: '#134e4a' },
|
||||
storage: { label: 'Storage', color: '#fdba74', bg: '#431407', border: '#7c2d12' },
|
||||
other: { label: 'Other', color: '#94a3b8', bg: '#0f172a', border: '#1e293b' },
|
||||
};
|
||||
|
||||
export const COMPONENT_TYPES: ComponentType[] = [
|
||||
'server', 'switch', 'router', 'firewall',
|
||||
'patch_panel', 'ups', 'pdu', 'kvm', 'storage', 'other',
|
||||
];
|
||||
|
||||
// ── Users & Audit types (for Settings page) ──────────────────────────────────
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email?: string;
|
||||
role: 'admin' | 'editor' | 'viewer';
|
||||
is_active: boolean;
|
||||
has_api_key: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AuditEntry {
|
||||
id: string;
|
||||
user_id?: string;
|
||||
username?: string;
|
||||
action: string;
|
||||
entity_type: string;
|
||||
entity_id?: string;
|
||||
entity_name?: string;
|
||||
changes?: string;
|
||||
ip_address?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface DbStats {
|
||||
sites: number;
|
||||
rooms: number;
|
||||
racks: number;
|
||||
components: number;
|
||||
ports: number;
|
||||
users: number;
|
||||
audit_entries: number;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noEmit": false
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
// In production (Docker build) VITE_BASE_PATH=/networkview/ is injected.
|
||||
// In development it falls back to '/' so the dev server works as-is.
|
||||
const basePath = process.env.VITE_BASE_PATH ?? '/';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: basePath,
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user