This commit is contained in:
+5
-31
@@ -1,33 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { InventoryPage } from './pages/InventoryPage';
|
||||||
import './App.css';
|
|
||||||
import { TopologyView } from './pages/TopologyView';
|
|
||||||
import { InventoryView } from './pages/InventoryView';
|
|
||||||
import { IPsView } from './pages/IPsView';
|
|
||||||
import { DiscoveryJobsView } from './pages/DiscoveryJobsView';
|
|
||||||
import { ServicesView } from './pages/ServicesView';
|
|
||||||
import type { ViewKey } from './types';
|
|
||||||
|
|
||||||
export function App() {
|
function App() {
|
||||||
const [current, setCurrent] = useState<ViewKey>('inventory');
|
return <InventoryPage />;
|
||||||
|
|
||||||
const commonProps = { onNavigate: setCurrent };
|
|
||||||
|
|
||||||
const renderView = () => {
|
|
||||||
switch (current) {
|
|
||||||
case 'topology':
|
|
||||||
return <TopologyView {...commonProps} />;
|
|
||||||
case 'inventory':
|
|
||||||
return <InventoryView {...commonProps} />;
|
|
||||||
case 'ips':
|
|
||||||
return <IPsView {...commonProps} />;
|
|
||||||
case 'discovery':
|
|
||||||
return <DiscoveryJobsView {...commonProps} />;
|
|
||||||
case 'services':
|
|
||||||
return <ServicesView {...commonProps} />;
|
|
||||||
default:
|
|
||||||
return <InventoryView {...commonProps} />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return renderView();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import type { InventoryTreeNode } from '../../types/inventory';
|
||||||
|
import './inventory.css';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
nodes: InventoryTreeNode[];
|
||||||
|
selectedId: string | null;
|
||||||
|
onSelect: (id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeIcon: Record<string, string> = {
|
||||||
|
site: '🏠',
|
||||||
|
area: '🗂️',
|
||||||
|
rack: '🧱',
|
||||||
|
physical_host: '🖥️',
|
||||||
|
vm: '📦',
|
||||||
|
lxc: '📦',
|
||||||
|
container: '📦',
|
||||||
|
app: '⚙️',
|
||||||
|
switch: '🔀',
|
||||||
|
firewall: '🛡️',
|
||||||
|
router: '📡',
|
||||||
|
ap: '📶',
|
||||||
|
camera: '📷',
|
||||||
|
storage: '💾',
|
||||||
|
other: '•',
|
||||||
|
};
|
||||||
|
|
||||||
|
function TreeNode({ node, selectedId, onSelect, level = 0 }: { node: InventoryTreeNode; selectedId: string | null; onSelect: (id: string) => void; level?: number }) {
|
||||||
|
const [open, setOpen] = useState(level < 1);
|
||||||
|
const hasChildren = node.children.length > 0 || node.children_count > 0;
|
||||||
|
const isSelected = selectedId === node.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="inventory-tree-node">
|
||||||
|
<div className={`inventory-tree-row ${isSelected ? 'selected' : ''}`} style={{ paddingLeft: `${level * 16 + 8}px` }}>
|
||||||
|
<button
|
||||||
|
className="inventory-tree-toggle"
|
||||||
|
onClick={() => hasChildren && setOpen((v) => !v)}
|
||||||
|
aria-label={open ? 'Collapse node' : 'Expand node'}
|
||||||
|
>
|
||||||
|
{hasChildren ? (open ? '▾' : '▸') : '·'}
|
||||||
|
</button>
|
||||||
|
<button className="inventory-tree-label" onClick={() => onSelect(node.id)}>
|
||||||
|
<span className="inventory-tree-icon">{typeIcon[node.type] ?? '•'}</span>
|
||||||
|
<span className="inventory-tree-name">{node.name}</span>
|
||||||
|
</button>
|
||||||
|
<div className="inventory-tree-badges">
|
||||||
|
{node.badges.map((badge) => (
|
||||||
|
<span key={badge} className="inventory-badge">{badge}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{open && node.children.length > 0 && (
|
||||||
|
<div>
|
||||||
|
{node.children.map((child) => (
|
||||||
|
<TreeNode key={child.id} node={child} selectedId={selectedId} onSelect={onSelect} level={level + 1} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InventoryTree({ nodes, selectedId, onSelect }: Props) {
|
||||||
|
const firstNode = useMemo(() => nodes[0]?.id ?? null, [nodes]);
|
||||||
|
return (
|
||||||
|
<section className="inventory-panel inventory-tree-panel">
|
||||||
|
<div className="inventory-panel-header">
|
||||||
|
<h2>Inventory</h2>
|
||||||
|
<span className="inventory-muted">Root: {firstNode ?? 'n/a'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="inventory-tree-list">
|
||||||
|
{nodes.map((node) => (
|
||||||
|
<TreeNode key={node.id} node={node} selectedId={selectedId} onSelect={onSelect} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import type { NodeContext } from '../../types/inventory';
|
||||||
|
import './inventory.css';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
context: NodeContext | null;
|
||||||
|
loading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function RelationList({ title, items }: { title: string; items: NodeContext['relations']['incoming'] }) {
|
||||||
|
return (
|
||||||
|
<div className="inventory-context-block">
|
||||||
|
<h3>{title}</h3>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="inventory-muted">Nessuna relazione</p>
|
||||||
|
) : (
|
||||||
|
<ul className="inventory-list">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.id}>
|
||||||
|
<strong>{item.type}</strong> · {item.layer} · {item.source_id} → {item.target_id}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NodeContextPanel({ context, loading }: Props) {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<section className="inventory-panel inventory-context-panel">
|
||||||
|
<div className="inventory-panel-header"><h2>Dettaglio nodo</h2></div>
|
||||||
|
<p className="inventory-muted">Caricamento...</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
return (
|
||||||
|
<section className="inventory-panel inventory-context-panel">
|
||||||
|
<div className="inventory-panel-header"><h2>Dettaglio nodo</h2></div>
|
||||||
|
<p className="inventory-muted">Seleziona un nodo dall'inventory.</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { item, parent, children, relations } = context;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="inventory-panel inventory-context-panel">
|
||||||
|
<div className="inventory-panel-header">
|
||||||
|
<h2>{item.name}</h2>
|
||||||
|
<span className="inventory-status">{item.type} · {item.status}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="inventory-context-grid">
|
||||||
|
<div className="inventory-context-block">
|
||||||
|
<h3>Physical</h3>
|
||||||
|
<p><strong>Parent:</strong> {parent ? parent.name : 'Nessuno'}</p>
|
||||||
|
<p><strong>Children:</strong> {children.length}</p>
|
||||||
|
{children.length > 0 && (
|
||||||
|
<ul className="inventory-list">
|
||||||
|
{children.map((child) => (
|
||||||
|
<li key={child.id}>{child.name} · {child.type}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="inventory-context-block">
|
||||||
|
<h3>Object</h3>
|
||||||
|
<p><strong>ID:</strong> {item.id}</p>
|
||||||
|
<p><strong>Vendor:</strong> {item.vendor ?? 'n/a'}</p>
|
||||||
|
<p><strong>Model:</strong> {item.model ?? 'n/a'}</p>
|
||||||
|
<p><strong>Notes:</strong> {item.notes ?? 'n/a'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RelationList title="Incoming relations" items={relations.incoming} />
|
||||||
|
<RelationList title="Outgoing relations" items={relations.outgoing} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
.inventory-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(320px, 380px) minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-panel {
|
||||||
|
background: #111827;
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-panel-header h2,
|
||||||
|
.inventory-context-block h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-tree-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-tree-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-tree-row.selected {
|
||||||
|
background: rgba(59, 130, 246, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-tree-toggle,
|
||||||
|
.inventory-tree-label {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-tree-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-tree-name {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-tree-badges {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-badge,
|
||||||
|
.inventory-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-context-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-context-block {
|
||||||
|
background: rgba(255,255,255,0.03);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-list {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-muted {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-empty,
|
||||||
|
.inventory-error {
|
||||||
|
padding: 24px;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.inventory-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-context-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { EntityResponse, InventoryTreeNode, ListResponse, NodeContext } from '../types/inventory';
|
||||||
|
|
||||||
|
async function handleResponse<T>(response: Response): Promise<T> {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchInventoryTree(): Promise<InventoryTreeNode[]> {
|
||||||
|
const response = await fetch('/api/inventory/tree');
|
||||||
|
const data = await handleResponse<ListResponse<InventoryTreeNode>>(response);
|
||||||
|
return data.items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchNodeContext(id: string): Promise<NodeContext> {
|
||||||
|
const response = await fetch(`/api/inventory/nodes/${id}/context`);
|
||||||
|
const data = await handleResponse<EntityResponse<NodeContext>>(response);
|
||||||
|
return data.item;
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { InventoryTree } from '../components/inventory/InventoryTree';
|
||||||
|
import { NodeContextPanel } from '../components/inventory/NodeContextPanel';
|
||||||
|
import { fetchInventoryTree, fetchNodeContext } from '../lib/inventoryApi';
|
||||||
|
import type { InventoryTreeNode, NodeContext } from '../types/inventory';
|
||||||
|
import '../components/inventory/inventory.css';
|
||||||
|
|
||||||
|
function findFirstNode(nodes: InventoryTreeNode[]): string | null {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.id) return node.id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InventoryPage() {
|
||||||
|
const [nodes, setNodes] = useState<InventoryTreeNode[]>([]);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [context, setContext] = useState<NodeContext | null>(null);
|
||||||
|
const [loadingTree, setLoadingTree] = useState(true);
|
||||||
|
const [loadingContext, setLoadingContext] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
setLoadingTree(true);
|
||||||
|
fetchInventoryTree()
|
||||||
|
.then((items) => {
|
||||||
|
if (!active) return;
|
||||||
|
setNodes(items);
|
||||||
|
const first = findFirstNode(items);
|
||||||
|
setSelectedId(first);
|
||||||
|
})
|
||||||
|
.catch((err: Error) => {
|
||||||
|
if (!active) return;
|
||||||
|
setError(err.message);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (active) setLoadingTree(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedId) return;
|
||||||
|
let active = true;
|
||||||
|
setLoadingContext(true);
|
||||||
|
fetchNodeContext(selectedId)
|
||||||
|
.then((item) => {
|
||||||
|
if (active) setContext(item);
|
||||||
|
})
|
||||||
|
.catch((err: Error) => {
|
||||||
|
if (active) setError(err.message);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (active) setLoadingContext(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [selectedId]);
|
||||||
|
|
||||||
|
if (loadingTree) {
|
||||||
|
return <div className="inventory-empty">Caricamento inventory...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div className="inventory-error">Errore inventory: {error}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="inventory-layout">
|
||||||
|
<InventoryTree nodes={nodes} selectedId={selectedId} onSelect={setSelectedId} />
|
||||||
|
<NodeContextPanel context={context} loading={loadingContext} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
export type ObjectType =
|
||||||
|
| 'site'
|
||||||
|
| 'area'
|
||||||
|
| 'rack'
|
||||||
|
| 'physical_host'
|
||||||
|
| 'vm'
|
||||||
|
| 'lxc'
|
||||||
|
| 'container'
|
||||||
|
| 'app'
|
||||||
|
| 'switch'
|
||||||
|
| 'firewall'
|
||||||
|
| 'router'
|
||||||
|
| 'ap'
|
||||||
|
| 'camera'
|
||||||
|
| 'storage'
|
||||||
|
| 'other';
|
||||||
|
|
||||||
|
export type ObjectStatus = 'active' | 'planned' | 'inactive' | 'retired' | 'unknown';
|
||||||
|
export type RelationType =
|
||||||
|
| 'contains'
|
||||||
|
| 'mounted_in'
|
||||||
|
| 'attached_to'
|
||||||
|
| 'connects_to'
|
||||||
|
| 'uplink_to'
|
||||||
|
| 'routes_via'
|
||||||
|
| 'bridges_to'
|
||||||
|
| 'depends_on'
|
||||||
|
| 'hosts_service'
|
||||||
|
| 'managed_by';
|
||||||
|
export type RelationLayer = 'physical' | 'network' | 'logical' | 'service';
|
||||||
|
|
||||||
|
export interface InventoryTreeNode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: ObjectType;
|
||||||
|
status: ObjectStatus;
|
||||||
|
badges: string[];
|
||||||
|
children_count: number;
|
||||||
|
children: InventoryTreeNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NetObject {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug?: string | null;
|
||||||
|
type: ObjectType;
|
||||||
|
parent_id?: string | null;
|
||||||
|
status: ObjectStatus;
|
||||||
|
vendor?: string | null;
|
||||||
|
model?: string | null;
|
||||||
|
serial?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Relation {
|
||||||
|
id: string;
|
||||||
|
type: RelationType;
|
||||||
|
layer: RelationLayer;
|
||||||
|
source_id: string;
|
||||||
|
target_id: string;
|
||||||
|
direction: 'forward';
|
||||||
|
source: 'manual' | 'snmp' | 'lldp' | 'arp' | 'nmap' | 'inference' | 'import';
|
||||||
|
confidence: number;
|
||||||
|
status: 'active' | 'stale' | 'planned' | 'deleted';
|
||||||
|
attributes: Record<string, unknown>;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeContext {
|
||||||
|
item: NetObject;
|
||||||
|
parent: NetObject | null;
|
||||||
|
children: NetObject[];
|
||||||
|
relations: {
|
||||||
|
incoming: Relation[];
|
||||||
|
outgoing: Relation[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListResponse<T> {
|
||||||
|
count: number;
|
||||||
|
items: T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntityResponse<T> {
|
||||||
|
item: T;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user