Infraestructura
This commit is contained in:
parent
c7fa4ad291
commit
4bfbb30be0
@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "node --env-file=../../.env --import tsx --watch src/index.ts",
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "node --import tsx --test src/app.test.ts src/config/env-schema.test.ts src/infrastructure/http/http-error-diagnostic.test.ts && DB_NAME=metis_desk_test NODE_ENV=test node --env-file=../../.env --import tsx --test --test-concurrency=1 src/asset-inventory.integration.test.ts src/sites.integration.test.ts src/identity.test.ts",
|
||||
"test": "node --import tsx --test src/app.test.ts src/infrastructure.http.test.ts src/config/env-schema.test.ts src/infrastructure/http/http-error-diagnostic.test.ts && DB_NAME=metis_desk_test NODE_ENV=test node --env-file=../../.env --import tsx --test --test-concurrency=1 src/asset-inventory.integration.test.ts src/infrastructure-inventory.integration.test.ts src/sites.integration.test.ts src/identity.test.ts",
|
||||
"build": "tsup --config tsup.config.ts",
|
||||
"start": "node --env-file=../../.env dist/index.js"
|
||||
},
|
||||
|
||||
158
apps/server/src/infrastructure-inventory.integration.test.ts
Normal file
158
apps/server/src/infrastructure-inventory.integration.test.ts
Normal file
@ -0,0 +1,158 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { QueryTypes } from "sequelize";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { InfrastructureRevision } from "../../../modules/assets/src/api/domain/infrastructure-revision.entity";
|
||||
import { InfrastructureValues } from "../../../modules/assets/src/api/domain/value-objects/infrastructure-values.vo";
|
||||
import { writeAssetRow } from "../../../modules/assets/src/api/infrastructure/persistence/asset-relational-sql";
|
||||
import {
|
||||
AddressRowSchema,
|
||||
InfrastructurePersistenceMapper,
|
||||
InfrastructureRevisionRowSchema,
|
||||
InfrastructureRoleRowSchema,
|
||||
InterfaceRowSchema,
|
||||
} from "../../../modules/assets/src/api/infrastructure/persistence/infrastructure-persistence.mapper";
|
||||
import { removeAssetInfrastructure } from "../../../modules/assets/src/api/infrastructure/persistence/remove-asset-infrastructure";
|
||||
import { writeInfrastructureRevision } from "../../../modules/assets/src/api/infrastructure/persistence/write-infrastructure-revision";
|
||||
import { UniqueID } from "../../../packages/rdx-ddd/src/index";
|
||||
|
||||
import { createDatabase, readDatabaseEnvironment } from "./config/database";
|
||||
|
||||
const TABLES = {
|
||||
asset_infrastructure: "id CHAR(36) PRIMARY KEY,companyId CHAR(36),assetId CHAR(36)",
|
||||
asset_infrastructure_revisions:
|
||||
"id CHAR(36) PRIMARY KEY,infrastructureId CHAR(36),actorId CHAR(36),recordedAt VARCHAR(30),position INT",
|
||||
asset_infrastructure_roles: "revisionId CHAR(36),role VARCHAR(30),PRIMARY KEY(revisionId,role)",
|
||||
asset_interface_snapshots:
|
||||
"revisionId CHAR(36),id CHAR(36),name VARCHAR(100),kind VARCHAR(12),mac VARCHAR(17),notes TEXT,position INT,PRIMARY KEY(revisionId,id)",
|
||||
asset_interface_address_snapshots:
|
||||
"revisionId CHAR(36),id CHAR(36),interfaceId CHAR(36),address VARCHAR(45),position INT,PRIMARY KEY(revisionId,id)",
|
||||
};
|
||||
|
||||
test("MariaDB: interfaces y direcciones conservan identidad e historial al cambiar la IP", async () => {
|
||||
const environment = readDatabaseEnvironment(process.env);
|
||||
|
||||
assert.equal(environment.NODE_ENV, "test");
|
||||
assert.equal(environment.DB_NAME, "metis_desk_test");
|
||||
const database = createDatabase(environment);
|
||||
|
||||
try {
|
||||
await database.transaction(async (transaction) => {
|
||||
const createdTables: string[] = [];
|
||||
|
||||
try {
|
||||
for (const [table, columns] of Object.entries(TABLES)) {
|
||||
await database.query(`CREATE TEMPORARY TABLE ${table} (${columns})`, { transaction });
|
||||
createdTables.push(table);
|
||||
}
|
||||
const root = {
|
||||
id: UniqueID.generateNewID().toPrimitive(),
|
||||
companyId: UniqueID.generateNewID().toPrimitive(),
|
||||
assetId: UniqueID.generateNewID().toPrimitive(),
|
||||
};
|
||||
|
||||
await writeAssetRow(database, "asset_infrastructure", root, transaction);
|
||||
const actor = UniqueID.generateNewID();
|
||||
const values = InfrastructureValues.create({
|
||||
roles: ["shared_peripheral"],
|
||||
interfaces: [
|
||||
{
|
||||
name: "Ethernet",
|
||||
kind: "ethernet",
|
||||
mac: "aa-bb-cc-dd-ee-ff",
|
||||
notes: "Conservar",
|
||||
addresses: [{ address: "192.168.5.201" }, { address: "2001:db8::1" }],
|
||||
},
|
||||
{ name: "Wi-Fi", kind: "wifi", mac: "", notes: "", addresses: [] },
|
||||
],
|
||||
});
|
||||
|
||||
assert.ok(values.isSuccess);
|
||||
const first = InfrastructureRevision.create({
|
||||
values: values.data,
|
||||
actorId: actor,
|
||||
recordedAt: "2026-10-17T10:00:00.000Z",
|
||||
});
|
||||
|
||||
await writeInfrastructureRevision(database, root.id, first, 0, transaction);
|
||||
const draft = values.data.toPrimitive();
|
||||
const iface = draft.interfaces[0];
|
||||
|
||||
assert.ok(iface);
|
||||
const address = iface.addresses[0];
|
||||
|
||||
assert.ok(address);
|
||||
address.address = "192.168.5.202";
|
||||
const changed = InfrastructureValues.create(draft, values.data);
|
||||
|
||||
assert.ok(changed.isSuccess);
|
||||
const second = InfrastructureRevision.create({
|
||||
values: changed.data,
|
||||
actorId: actor,
|
||||
recordedAt: "2026-10-17T11:00:00.000Z",
|
||||
});
|
||||
|
||||
await writeInfrastructureRevision(database, root.id, second, 1, transaction);
|
||||
|
||||
const read = (table: string) =>
|
||||
database.query(`SELECT * FROM ${table}`, { type: QueryTypes.SELECT, transaction });
|
||||
|
||||
const revisions = z
|
||||
.array(InfrastructureRevisionRowSchema)
|
||||
.parse(await read("asset_infrastructure_revisions"));
|
||||
const roles = z
|
||||
.array(InfrastructureRoleRowSchema)
|
||||
.parse(await read("asset_infrastructure_roles"));
|
||||
const interfaces = z
|
||||
.array(InterfaceRowSchema)
|
||||
.parse(await read("asset_interface_snapshots"));
|
||||
const addresses = z
|
||||
.array(AddressRowSchema)
|
||||
.parse(await read("asset_interface_address_snapshots"));
|
||||
const restored = new InfrastructurePersistenceMapper().toDomain(
|
||||
root,
|
||||
revisions,
|
||||
roles,
|
||||
interfaces,
|
||||
addresses
|
||||
);
|
||||
|
||||
assert.deepEqual(restored.values.toPrimitive(), changed.data.toPrimitive());
|
||||
assert.deepEqual(restored.history[0]?.values.toPrimitive(), values.data.toPrimitive());
|
||||
assert.equal(restored.history.length, 2);
|
||||
assert.equal(restored.values.toPrimitive().interfaces[0]?.id, iface.id);
|
||||
assert.equal(restored.values.toPrimitive().interfaces[0]?.addresses[0]?.id, address.id);
|
||||
const corrupt = revisions.map((row, index) => ({ ...row, position: index + 2 }));
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
new InfrastructurePersistenceMapper().toDomain(
|
||||
root,
|
||||
corrupt,
|
||||
roles,
|
||||
interfaces,
|
||||
addresses
|
||||
),
|
||||
/Secuencia/
|
||||
);
|
||||
await removeAssetInfrastructure(database, root.companyId, root.assetId, transaction);
|
||||
for (const table of Object.keys(TABLES)) {
|
||||
const remaining = await database.query(`SELECT 1 FROM ${table}`, {
|
||||
type: QueryTypes.SELECT,
|
||||
transaction,
|
||||
});
|
||||
|
||||
assert.equal(remaining.length, 0);
|
||||
}
|
||||
} finally {
|
||||
for (const table of createdTables.reverse()) {
|
||||
await database.query(`DROP TEMPORARY TABLE ${table}`, { transaction });
|
||||
}
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
await database.close();
|
||||
}
|
||||
});
|
||||
159
apps/server/src/infrastructure.http.test.ts
Normal file
159
apps/server/src/infrastructure.http.test.ts
Normal file
@ -0,0 +1,159 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { once } from "node:events";
|
||||
import { test } from "node:test";
|
||||
|
||||
import express from "express";
|
||||
|
||||
import type { IInfrastructureRepository } from "../../../modules/assets/src/api/application/infrastructure.repository";
|
||||
import { InfrastructureUseCases } from "../../../modules/assets/src/api/application/infrastructure.use-cases";
|
||||
import { AssetCatalogError } from "../../../modules/assets/src/api/domain/asset-definition";
|
||||
import type { AssetInfrastructure } from "../../../modules/assets/src/api/domain/asset-infrastructure.aggregate";
|
||||
import { registerInfrastructureRoutes } from "../../../modules/assets/src/api/infrastructure/http/infrastructure.routes";
|
||||
import {
|
||||
InfrastructureResponseSchema,
|
||||
SavedInfrastructureResponseSchema,
|
||||
} from "../../../modules/assets/src/common/infrastructure";
|
||||
import { UniqueID } from "../../../packages/rdx-ddd/src/index";
|
||||
import { Result } from "../../../packages/rdx-utils/src/index";
|
||||
|
||||
// Repositorio en memoria: el servidor HTTP, los contratos y el dominio son reales.
|
||||
test("HTTP infraestructura: sesión, pertenencia, validación, identidad e idempotencia", async () => {
|
||||
const company = UniqueID.generateNewID();
|
||||
const asset = UniqueID.generateNewID();
|
||||
const actor = UniqueID.generateNewID();
|
||||
let current: AssetInfrastructure | undefined;
|
||||
const actorNames = new Map([[actor.toPrimitive(), "Técnico"]]);
|
||||
const repository: IInfrastructureRepository = {
|
||||
get: async (id) =>
|
||||
id.equals(company)
|
||||
? Result.ok({ items: current ? [current] : [], actorNames })
|
||||
: Result.fail(new AssetCatalogError("COMPANY_NOT_FOUND")),
|
||||
save: async (id, assetId, change) => {
|
||||
if (!(id.equals(company) && assetId.equals(asset))) {
|
||||
return Result.fail(new AssetCatalogError("ASSET_NOT_FOUND"));
|
||||
}
|
||||
const changed = change(current);
|
||||
|
||||
if (!changed.isSuccess) {
|
||||
return Result.fail(changed.error);
|
||||
}
|
||||
current = changed.data;
|
||||
|
||||
return Result.ok({ item: current, actorNames });
|
||||
},
|
||||
};
|
||||
const app = express();
|
||||
|
||||
app.use(express.json());
|
||||
registerInfrastructureRoutes(
|
||||
app,
|
||||
new InfrastructureUseCases(repository),
|
||||
(request, response, next) => {
|
||||
if (request.get("authorization") !== "test") {
|
||||
response.status(401).json({ code: "UNAUTHENTICATED" });
|
||||
|
||||
return;
|
||||
}
|
||||
Object.defineProperty(request, "session", { value: { userId: actor.toPrimitive() } });
|
||||
next();
|
||||
}
|
||||
);
|
||||
const server = app.listen(0, "127.0.0.1");
|
||||
|
||||
await once(server, "listening");
|
||||
const address = server.address();
|
||||
|
||||
assert.ok(address && typeof address !== "string");
|
||||
const base = `http://127.0.0.1:${address.port}/api/asset-infrastructure`;
|
||||
|
||||
const request = (path: string, body?: unknown) =>
|
||||
fetch(`${base}/${path}`, {
|
||||
headers: { authorization: "test", "content-type": "application/json" },
|
||||
...(body ? { method: "POST", body: JSON.stringify(body) } : {}),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
|
||||
try {
|
||||
assert.equal(
|
||||
(await fetch(`${base}/${company.toPrimitive()}`, { signal: AbortSignal.timeout(10000) }))
|
||||
.status,
|
||||
401
|
||||
);
|
||||
const empty = await request(company.toPrimitive());
|
||||
|
||||
assert.equal(empty.status, 200);
|
||||
assert.deepEqual(InfrastructureResponseSchema.parse(await empty.json()).items, []);
|
||||
const path = `${company.toPrimitive()}/${asset.toPrimitive()}`;
|
||||
const input = {
|
||||
roles: ["shared_peripheral"],
|
||||
interfaces: [
|
||||
{
|
||||
name: "Ethernet",
|
||||
kind: "ethernet",
|
||||
mac: "",
|
||||
notes: "",
|
||||
addresses: [{ address: "192.168.5.201" }],
|
||||
},
|
||||
{ name: "Wi-Fi", kind: "wifi", mac: "", notes: "", addresses: [] },
|
||||
],
|
||||
};
|
||||
|
||||
for (const expected of [1, 1]) {
|
||||
const saved = await request(path, { values: input });
|
||||
|
||||
assert.equal(saved.status, 200);
|
||||
const item = SavedInfrastructureResponseSchema.parse(await saved.json()).item;
|
||||
|
||||
assert.equal(item.history.length, expected);
|
||||
assert.equal(item.history[0]?.actorId, actor.toPrimitive());
|
||||
assert.equal(item.values.interfaces[0]?.addresses[0]?.address, "192.168.5.201");
|
||||
}
|
||||
assert.equal(
|
||||
(await request(path, { values: { ...input, companyId: company.toPrimitive() } })).status,
|
||||
400
|
||||
);
|
||||
assert.equal((await request(`${path}?unexpected=1`, { values: input })).status, 400);
|
||||
assert.equal(
|
||||
(
|
||||
await request(path, {
|
||||
values: {
|
||||
...input,
|
||||
interfaces: [{ ...input.interfaces[0], addresses: [{ address: "invalid" }] }],
|
||||
},
|
||||
})
|
||||
).status,
|
||||
400
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await request(`${UniqueID.generateNewID().toPrimitive()}/${asset.toPrimitive()}`, {
|
||||
values: input,
|
||||
})
|
||||
).status,
|
||||
404
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await request(`${company.toPrimitive()}/${UniqueID.generateNewID().toPrimitive()}`, {
|
||||
values: input,
|
||||
})
|
||||
).status,
|
||||
404
|
||||
);
|
||||
const restored = await request(company.toPrimitive());
|
||||
const items = InfrastructureResponseSchema.parse(await restored.json()).items;
|
||||
|
||||
assert.equal(items.length, 1);
|
||||
assert.equal(items[0]?.history.length, 1);
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
383
docs/auditoria-de-infraestructura-informatica.md
Normal file
383
docs/auditoria-de-infraestructura-informatica.md
Normal file
@ -0,0 +1,383 @@
|
||||
# Auditoría de infraestructura informática y propuesta para Metis Desk
|
||||
|
||||
Fecha: 17 de octubre de 2026. Estado: propuesta revisada con el alcance funcional
|
||||
acordado. El núcleo de la primera entrega está descrito en
|
||||
[Infraestructura informática](infraestructura-informatica.md); los casos adicionales
|
||||
se mantienen como ampliaciones posteriores.
|
||||
|
||||
## Resultado y límite acordado
|
||||
|
||||
Metis Desk debe ofrecer un inventario con conexiones entre equipos, interfaces y
|
||||
sus direcciones IP, y una consulta de dónde se utiliza cada activo. Se conservan
|
||||
las plantillas, los activos, los puestos, los perfiles y las relaciones existentes.
|
||||
La vista Infraestructura reúne esa información por compañía.
|
||||
|
||||
Ejemplo del nivel requerido: «La impresora XXX está conectada al switch 1 y se utiliza
|
||||
en A1, A2, A5 y P1». Si dispone de Ethernet y Wi-Fi, se registran ambas interfaces
|
||||
con sus respectivas direcciones, sin exigir que ambas estén configuradas o en uso.
|
||||
|
||||
El controlador UniFi ya proporciona al usuario el detalle de su infraestructura de
|
||||
red. Por tanto, la propuesta de Metis Desk no incluye puertos numerados de switches,
|
||||
cables, rosetas, ocupación de conectores, VLAN, subredes, tablas de rutas, IPAM avanzado
|
||||
ni monitorización o descubrimiento automático. Tampoco exige sincronizar UniFi para
|
||||
poder registrar los datos básicos. Una integración futura requeriría un alcance propio.
|
||||
|
||||
Las interfaces se registran para identificar el medio y sus IP. No representan un
|
||||
inventario exhaustivo de todos los conectores de un switch. La información detallada
|
||||
de red permanece en UniFi; Metis Desk mantiene la información de equipos y uso necesaria
|
||||
para el soporte, sin prometer que ambos sistemas estén sincronizados.
|
||||
|
||||
## Alcance y método de la auditoría
|
||||
|
||||
Revisión de código y documentación activa del repositorio y auditoría documental de
|
||||
seis productos. Se han consultado fuentes oficiales; no se han instalado ni probado
|
||||
sus interfaces. La comparación analiza modelos funcionales, no precios, rendimiento
|
||||
ni disponibilidad comercial de cada función en todas las ediciones. Las conclusiones
|
||||
para Metis Desk son recomendaciones propias, diferenciadas de lo documentado por los proveedores.
|
||||
|
||||
La comparación de mercado se conserva como referencia. Que una herramienta gestione
|
||||
puertos o VLAN no convierte esas capacidades en requisitos para Metis Desk.
|
||||
|
||||
No se ha explorado la red de Abeto Design ni modificado su inventario. Este documento
|
||||
no acredita conexiones o instalaciones reales de esa compañía.
|
||||
|
||||
## Situación actual comprobada
|
||||
|
||||
| Área | Existe hoy | Ajuste necesario para el alcance acordado |
|
||||
| --- | --- | --- |
|
||||
| Inventario | Activos con compañía, sede, estado técnico, marca, plantilla e historial | Reunir la infraestructura y facilitar la consulta de sus conexiones y uso |
|
||||
| Plantillas | Router, firewall, switch, punto de acceso, NAS, servidor, impresora y otros equipos | Mantener sus datos descriptivos; separar las interfaces y direcciones repetibles |
|
||||
| Relaciones | Conectado a, Alojado en, Depende de, Alimentado por e Instalado en; tipos propios | Reutilizar los vínculos entre activos, suficientes para indicar impresora ↔ switch |
|
||||
| Puestos | Ordenador principal, responsable opcional, sede y composición | Conservar la exclusividad; los dispositivos compartidos no se duplican entre puestos |
|
||||
| Perfiles | Contexto de persona y equipo para documentar configuraciones | Mostrar qué equipos y perfiles utilizan cada activo compartido |
|
||||
| Impresión | Configuración perfil/impresora, USB o red, predeterminada y estados de preparación/retirada | Obtener una lista de equipos usuarios, distinguiendo uso configurado de tareas pendientes |
|
||||
| Software | Aplicación y licencia como recursos; instalación vinculada a activo | Mantener su responsabilidad propia, separada de las conexiones de red |
|
||||
| Carpetas | Recursos y configuración de acceso/mapeo con ruta | Relacionar explícitamente el recurso con el servidor o NAS cuando se necesite documentar su anfitrión |
|
||||
|
||||
Evidencias locales:
|
||||
|
||||
- [Catálogo de activos](../modules/assets/src/api/domain/asset-catalog-seed.ts).
|
||||
- [Reserva exclusiva de miembros del puesto](../modules/assets/src/api/application/workstation-input.mapper.ts).
|
||||
- [Valores de las relaciones](../modules/assets/src/api/domain/value-objects/asset-relationship-values.vo.ts).
|
||||
- [Tipos de campos y plantillas](../modules/assets/src/api/domain/asset-definition.ts).
|
||||
- [Relaciones entre activos](relaciones-entre-activos.md).
|
||||
- [Puestos de trabajo](puestos-de-trabajo.md).
|
||||
- [Impresoras del perfil](impresoras-del-perfil.md).
|
||||
- [Contrato de impresión](../modules/companies/src/api/domain/printer-configuration-values.ts).
|
||||
- [Contrato de instalación de software](../modules/companies/src/api/domain/software-installation-values.ts).
|
||||
|
||||
Consecuencias:
|
||||
|
||||
1. Una impresora compartida puede registrarse hoy como un único activo y vincularse
|
||||
a varios perfiles. No necesita incorporarse a cada puesto.
|
||||
2. Una impresora USB puede formar parte de un puesto y mantener una relación con el
|
||||
ordenador. Esa composición no acredita una instalación de controlador.
|
||||
3. Un servidor, NAS, switch o router puede existir sin puesto. Los servidores que hoy
|
||||
se utilizan excepcionalmente como ordenador principal conservan ese caso de uso;
|
||||
no se propone migrarlos ni bloquearlos automáticamente.
|
||||
4. «Conectado a» ya permite representar el nivel de conexión solicitado. No hace falta
|
||||
sustituirlo por enlaces entre puertos ni representar cada cable paralelo.
|
||||
5. Falta una estructura sencilla de interfaces y direcciones, junto con una vista
|
||||
consolidada del uso que aproveche las configuraciones ya existentes.
|
||||
|
||||
## Comparación documental
|
||||
|
||||
| Herramienta | Tratamiento documentado | Aprendizaje para Metis Desk |
|
||||
| --- | --- | --- |
|
||||
| GLPI | Puertos asociados a equipos, Ethernet/Wi-Fi y variantes virtuales; conexiones entre puertos, MAC, VLAN e historial | Tomar el inventario y las interfaces; mantener el detalle de puertos en UniFi |
|
||||
| iTop 3.3 | Dispositivos, servidores e impresoras como elementos propios; interfaces, VLAN y relaciones de impacto/dependencia | Separar el dispositivo de las funciones y dependencias que presta |
|
||||
| Jira Service Management Assets | Objetos relacionados por referencias entrantes/salientes y tipos de referencia | Mantener referencias navegables; una ficha aislada no explica la infraestructura |
|
||||
| ServiceNow CMDB | Relaciones diferenciadas de conexión, alojamiento, dependencia y pertenencia | Dar significado explícito a cada relación y distinguir conexión de impacto |
|
||||
| ManageEngine ServiceDesk Plus | CMDB con mapas de relaciones y dependencias; integración con herramientas de observabilidad | Consultar conexiones y uso; las integraciones no son requisito de esta propuesta |
|
||||
| NetBox | Interfaces de dispositivos, IP, VLAN y cableado entre extremos | Tomar la asociación de direcciones a interfaces; su cableado e IPAM exceden el alcance acordado |
|
||||
|
||||
Fuentes y alcance de las afirmaciones:
|
||||
|
||||
- **GLPI:** [Network ports](https://help.glpi-project.org/documentation/tabs/network_ports)
|
||||
y [Connections](https://help.glpi-project.org/documentation/tabs/connections).
|
||||
La documentación distingue conexiones de equipos y puertos de red y permite datos manuales o de inventario automático.
|
||||
- **iTop:** [CMDB 3.3](https://www.itophub.io/wiki/page?id=3_3_0:datamodel:itop-config-mgmt)
|
||||
y [dispositivos de usuario 3.3](https://www.itophub.io/wiki/page?id=3_3_0:datamodel:itop-endusers-devices).
|
||||
Se documentan dispositivos de red, interfaces físicas, VLAN e impresoras conectadas a red o PC.
|
||||
- **Atlassian:** [referencias entre objetos](https://support.atlassian.com/assets/docs/what-is-a-reference/).
|
||||
Las referencias tienen dirección y un tipo que expresa su significado.
|
||||
- **ServiceNow:** [relaciones entre CI](https://www.servicenow.com/docs/r/de-DE/servicenow-platform/configuration-management-database-cmdb/c_CIRelationships.html).
|
||||
La tabla distingue Connects to, Depends on, Hosted on y otras relaciones.
|
||||
- **ManageEngine:** [CMDB de ServiceDesk Plus](https://www.manageengine.com/products/service-desk/itsm/it-cmdb-software.html)
|
||||
y [sincronización con OpManager](https://www.manageengine.com/network-monitoring/syncing-relationship-data.html).
|
||||
La descripción del proveedor contempla mapas e integración; no implica que toda edición los incluya.
|
||||
- **NetBox:** [interfaces](https://netbox.readthedocs.io/en/stable/models/dcim/interface/),
|
||||
[direcciones IP](https://netbox.readthedocs.io/en/stable/models/ipam/ipaddress/)
|
||||
y [cables](https://netbox.readthedocs.io/en/stable/models/dcim/cable/).
|
||||
La documentación sitúa IP/VLAN en interfaces y representa conexiones físicas mediante extremos de cable.
|
||||
|
||||
La conclusión transversal de esta revisión es que los equipos compartidos conservan
|
||||
su identidad y se conectan a otros registros. La asignación personal es una dimensión
|
||||
más, junto a ubicación, conexiones y uso.
|
||||
|
||||
## Modelo funcional recomendado
|
||||
|
||||
| Concepto | Qué representa | Ejemplo |
|
||||
| --- | --- | --- |
|
||||
| Plantilla de activo | Datos descriptivos de un tipo de equipo | Impresora, NAS, switch |
|
||||
| Activo | Un equipo concreto, físico o virtual cuando corresponda | Impresora XXX, switch 1, ordenador A1 |
|
||||
| Puesto | Equipo principal y elementos asignados exclusivamente al conjunto | PC + monitores + impresora USB dedicada |
|
||||
| Perfil | Contexto de usuario/equipo y sus configuraciones | Perfil de una persona en A1 |
|
||||
| Interfaz | Medio de conexión de un activo que interesa documentar | Ethernet LAN, Wi-Fi, interfaz de gestión |
|
||||
| Dirección IP | Dirección asociada a una interfaz concreta | Ethernet: 192.168.5.201; Wi-Fi: 192.168.5.202 |
|
||||
| Conexión entre activos | Vínculo general entre dos equipos | Impresora XXX conectada al switch 1 |
|
||||
| Configuración de uso | Cómo un perfil o equipo consume un activo o recurso | Impresora configurada en A1 o carpeta mapeada |
|
||||
| Recurso publicado | Algo consumible ofrecido por un anfitrión | Carpeta compartida de un NAS |
|
||||
|
||||
### Plantillas y clasificación
|
||||
|
||||
Marca, modelo, serie, capacidad, características de hardware, firmware y datos
|
||||
específicos del producto siguen en las plantillas. Hubs, gateways, controladoras y
|
||||
otros equipos pueden usar plantillas propias.
|
||||
|
||||
Una clasificación funcional estable debe permitir filtrar familias sin depender
|
||||
del nombre traducido ni del icono. Un gateway también puede hacer de router,
|
||||
firewall y punto de acceso: registrar un solo aparato y sus funciones. La clasificación
|
||||
exacta se debe acordar antes de modificar contratos; el icono sigue siendo presentación.
|
||||
|
||||
Las interfaces y sus IP requieren estructuras relacionadas. Evitar campos fijos
|
||||
IP Ethernet/IP Wi-Fi, IP1/IP2 o listas separadas por comas, porque un activo puede
|
||||
tener varias interfaces del mismo tipo y una interfaz puede tener varias direcciones.
|
||||
|
||||
### Interfaces e IP: información mínima
|
||||
|
||||
Por cada interfaz que interese documentar:
|
||||
|
||||
- Nombre o identificador comprensible: Ethernet, LAN1, Wi-Fi o Gestión.
|
||||
- Tipo de conexión, inicialmente Ethernet, Wi-Fi y Otro.
|
||||
- Cero, una o varias direcciones IP, admitiendo IPv4 e IPv6.
|
||||
- MAC opcional y observaciones opcionales.
|
||||
|
||||
Una impresora puede tener Ethernet y Wi-Fi aunque solo Ethernet tenga una IP conocida.
|
||||
Una interfaz sin dirección conocida no obliga a inventarla ni a borrar la interfaz.
|
||||
En las tablas, las celdas sin valor muestran «—» conforme al patrón común.
|
||||
|
||||
La IP identifica una configuración, no la identidad del activo. Cambiar una IP conserva
|
||||
el activo y su historial. Una misma dirección privada puede existir en compañías
|
||||
diferentes. Dentro de una compañía, una coincidencia puede generar un aviso revisable;
|
||||
no imponer unicidad absoluta sin conocer los contextos de red, que quedan fuera del alcance.
|
||||
No es necesario administrar prefijos, máscaras, rangos, DHCP ni VLAN para guardar una IP.
|
||||
|
||||
### Conexiones entre equipos
|
||||
|
||||
Reutilizar «Conectado a» como vínculo entre dos activos de la misma compañía.
|
||||
Debe poder consultarse en ambas fichas y conservar su vigencia e historial actual.
|
||||
Ejemplos: impresora ↔ switch, PC ↔ switch, AP ↔ switch y NAS ↔ switch.
|
||||
|
||||
La conexión se registra aunque no se conozca la interfaz. Si interesa asociarla a
|
||||
una interfaz documentada, se puede añadir una referencia opcional sobre ese mismo
|
||||
vínculo, sin crear una segunda conexión independiente ni exigir el puerto remoto.
|
||||
La concreta necesidad de esa referencia se resolverá en el diseño técnico.
|
||||
|
||||
Las relaciones existentes siguen siendo válidas y completas para este nivel de
|
||||
detalle. No se marcan como incompletas por carecer de extremos de cable. Una sola
|
||||
relación puede resumir la conexión entre dos equipos, incluso si UniFi conoce varios
|
||||
enlaces físicos. Las asociaciones se documentan; no acreditan disponibilidad en tiempo real.
|
||||
|
||||
### Uso exclusivo, compartido y «Dónde se utiliza»
|
||||
|
||||
La exclusividad pertenece a la composición activa del puesto. Un activo compartido
|
||||
puede ser utilizado por varios perfiles o equipos sin adquirir varias identidades ni
|
||||
reservas exclusivas. Mantener la protección del ordenador y sus periféricos dedicados.
|
||||
|
||||
Para impresoras, la fuente de verdad del uso son las configuraciones de impresión
|
||||
existentes. Desde ellas se obtiene el equipo de cada perfil y, cuando exista, su
|
||||
puesto. La consulta «Dónde se utiliza» muestra equipos únicos —A1, A2, A5, P1— y
|
||||
permite desplegar sus perfiles/usuarios y su configuración de impresora predeterminada.
|
||||
Si varios perfiles de A1 utilizan la impresora, A1 aparece una sola vez en el resumen.
|
||||
|
||||
Distinguir configurada, pendiente de configurar y pendiente de retirar. Las configuraciones
|
||||
retiradas o que nunca llegaron a configurarse permanecen en el historial, sin presentarse
|
||||
como uso actual. Los avisos de referencias inactivas se conservan. No afirmar un uso
|
||||
real observado: la información describe las configuraciones registradas en Metis Desk.
|
||||
|
||||
No mantener una segunda lista editable de ordenadores usuarios para las mismas
|
||||
configuraciones. Si aparece un uso a nivel de equipo sin perfil, se diseñará una
|
||||
asociación específica para ese caso y se integrará en la consulta agregada.
|
||||
|
||||
Estar conectado a un switch no significa utilizar la impresora. Estar en la misma
|
||||
sede tampoco concede acceso. Conexión, uso y asignación exclusiva son dimensiones distintas.
|
||||
|
||||
### Impresión local y compartida
|
||||
|
||||
| Caso | Conexión documentada | Uso documentado |
|
||||
| --- | --- | --- |
|
||||
| USB dedicada | Impresora ↔ PC; puede pertenecer al puesto | Perfil configura esa impresora local |
|
||||
| Impresora Ethernet | Impresora ↔ switch; interfaz Ethernet con sus IP si se conocen | Varios perfiles/equipos configuran el mismo activo |
|
||||
| Impresora Wi-Fi | Impresora ↔ AP cuando interese y se conozca; interfaz Wi-Fi con sus IP | Varios perfiles/equipos configuran el mismo activo |
|
||||
| USB compartida desde un PC | Impresora ↔ PC anfitrión; no necesita una IP propia | Otros equipos utilizan la impresora compartida desde ese anfitrión |
|
||||
|
||||
Para impresoras publicadas por un PC o servidor, registrar el anfitrión y el nombre
|
||||
compartido o ruta cuando ese caso lo requiera. El modelo completo de colas y servidores
|
||||
de impresión se reserva para una ampliación posterior; no es requisito para consultar
|
||||
los equipos que hoy utilizan una impresora de red mediante los perfiles existentes.
|
||||
|
||||
### Servidores, NAS y recursos
|
||||
|
||||
Registrar cada servidor o NAS una vez, con sus interfaces/IP y conexiones generales.
|
||||
Los roles —archivos, dominio, DNS, DHCP, impresión o aplicaciones— describen sus funciones;
|
||||
no crear un activo duplicado por cada función ni exigir un puesto para administrarlo.
|
||||
|
||||
Una VM puede ser otro activo relacionado mediante «Alojado en», conservando identidad
|
||||
e historial al cambiar de anfitrión. El NAS es el dispositivo y sus carpetas son recursos.
|
||||
La relación explícita entre recurso y anfitrión se incorporará cuando ese caso sea necesario,
|
||||
conservando los permisos y configuraciones actuales. No forma parte del mínimo de red
|
||||
el modelado exhaustivo de servicios, clusters o dependencias de aplicaciones.
|
||||
|
||||
## Ejemplo de ficha de una impresora
|
||||
|
||||
Ejemplo ilustrativo del alcance, no inventario ni conexiones verificados de Abeto Design:
|
||||
|
||||
| Apartado | Contenido |
|
||||
| --- | --- |
|
||||
| Información | Impresora XXX, modelo, serie, sede y estado |
|
||||
| Interfaces | Ethernet: 192.168.5.201; Wi-Fi: 192.168.5.202 |
|
||||
| Conexiones | Switch 1 por Ethernet; AP 1 por Wi-Fi, si se conoce y está documentado |
|
||||
| Dónde se utiliza | A1, A2, A5 y P1; perfiles y estados de configuración asociados |
|
||||
| Historial | Cambios de datos, direcciones, conexiones y configuraciones |
|
||||
|
||||
Las dos direcciones son ilustrativas; no se presupone que ambas interfaces estén
|
||||
configuradas o conectadas simultáneamente. Tampoco es obligatorio conocer el AP.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Printer[Impresora XXX] ---|Conectada a| Switch[Switch 1]
|
||||
Printer --- Ethernet[Ethernet: 192.168.5.201]
|
||||
Printer --- Wifi[Wi-Fi: 192.168.5.202]
|
||||
A1[Equipo A1] -. Configuración de impresión .-> Printer
|
||||
A2[Equipo A2] -. Configuración de impresión .-> Printer
|
||||
A5[Equipo A5] -. Configuración de impresión .-> Printer
|
||||
P1[Equipo P1] -. Configuración de impresión .-> Printer
|
||||
```
|
||||
|
||||
El esquema muestra conexión, interfaces y uso. Las líneas de uso son una consulta
|
||||
agregada de los perfiles, no nuevas asociaciones editables que dupliquen sus datos.
|
||||
|
||||
## Diseño de persistencia propuesto
|
||||
|
||||
Propuesta conceptual, no esquema aprobado ni migración lista para ejecutar:
|
||||
|
||||
- `asset_interfaces`: identidad UUID v7, compañía, activo, nombre, tipo de conexión,
|
||||
MAC opcional y observaciones. Solo interfaces relevantes para documentar el activo.
|
||||
- `asset_interface_addresses`: identidad UUID v7, interfaz y dirección IP normalizada.
|
||||
Mantener las referencias y validaciones de pertenencia a compañía.
|
||||
- `asset_relationships`: reutilizar la entidad existente para conexiones entre equipos,
|
||||
alojamiento y dependencias. No crear `network_connections` para puertos o cables.
|
||||
- `printer_configurations`: conservar la fuente de verdad del uso de impresoras;
|
||||
obtener equipos/perfiles/puestos mediante consultas, sin copiar listas de usuarios.
|
||||
|
||||
No se proponen tablas de puertos de switch, cableado, VLAN, segmentos, pools o rangos
|
||||
IP. Las futuras publicaciones de recursos o usos sin perfil se diseñarán por separado
|
||||
cuando existan casos concretos, sin bloquear esta entrega.
|
||||
|
||||
Todas las entidades de negocio, incluidas las internas, requieren identidad propia.
|
||||
Mantener FK, validación de compañía, transacciones, historial, idempotencia y migraciones
|
||||
explícitas. La dirección no sustituye el UUID del activo o interfaz. Las referencias
|
||||
repetibles se almacenan de forma relacional, sin listas de IP en texto ni JSON.
|
||||
Los módulos consumidores acceden por servicios públicos; el ORM permanece en Infrastructure.
|
||||
MariaDB y la arquitectura existente cubren este alcance.
|
||||
|
||||
Los campos IP o MAC que ya existan en características o notas requieren una revisión
|
||||
antes de proponer migración. No eliminarlos ni reinterpretarlos automáticamente.
|
||||
Una migración futura debe identificar la interfaz cuando se conozca, conservar el
|
||||
historial y establecer una única fuente editable para la información normalizada.
|
||||
|
||||
## Cambios de interfaz recomendados
|
||||
|
||||
Añadir **Infraestructura** en la compañía como vista del mismo inventario, con filtros
|
||||
de equipos de red, servidores/almacenamiento y periféricos compartidos. Mostrar nombre,
|
||||
tipo, sede, estado, direcciones y vínculos generales según el contenido disponible.
|
||||
Estos filtros necesitan clasificación funcional explícita; no deben usar iconos como
|
||||
reglas de negocio. Un equipo puede aparecer en varias vistas por sus funciones.
|
||||
|
||||
La ficha del activo reúne **Información**, **Interfaces**, **Conexiones**,
|
||||
**Dónde se utiliza** e **Historial**, según corresponda. Los apartados pueden ser
|
||||
secciones de la ficha; no se exige crear una pestaña por cada uno. Reutilizar
|
||||
componentes, listados, selectores y diálogos existentes.
|
||||
|
||||
- Impresora: interfaces con IP, dispositivos conectados y equipos/perfiles que la utilizan.
|
||||
- Switch: equipos conectados a nivel de activo, sin cuadrícula ni ocupación de puertos.
|
||||
- Servidor/NAS: interfaces, conexiones y relaciones de alojamiento o recursos ya documentadas.
|
||||
- Puesto: componentes exclusivos y consulta de uso compartido derivada de sus perfiles,
|
||||
sin incorporar la impresora compartida a su reserva exclusiva.
|
||||
|
||||
Una representación gráfica sencilla es opcional y posterior a las consultas en tabla.
|
||||
Un acceso manual al controlador UniFi puede añadirse si resulta útil; no se presupone
|
||||
API, descubrimiento, enlace profundo a un dispositivo ni sincronización automática.
|
||||
|
||||
## Alternativas y recomendación
|
||||
|
||||
| Alternativa | Ventaja | Problema | Decisión propuesta |
|
||||
| --- | --- | --- | --- |
|
||||
| IP y uso en notas o campos fijos | Entrega mínima | No permite consultar y mantener varias interfaces/IP o referencias con claridad | Documentación transitoria |
|
||||
| Quitar la exclusividad de los puestos | Aparente simplicidad | Mezcla reserva, conexión y uso compartido | Descartar |
|
||||
| Inventario, relaciones entre equipos, interfaces/IP y uso derivado | Aprovecha el modelo actual y cubre el límite solicitado | Requiere interfaces/IP y consultas consolidadas | Recomendada |
|
||||
| Replicar el detalle de red de UniFi | Más información técnica | Duplica una función que el usuario ya tiene cubierta | Fuera del alcance |
|
||||
|
||||
La recomendación es propia. De GLPI se toma el inventario y la distinción entre medios;
|
||||
de iTop, Atlassian y ServiceNow, las relaciones; de NetBox, el concepto de direcciones
|
||||
asociadas a interfaces. Sus funciones más amplias no se trasladan como obligaciones.
|
||||
|
||||
## Orden de implantación y aceptación
|
||||
|
||||
### 1. Hacer visible la infraestructura y su uso
|
||||
|
||||
Acordar la clasificación funcional y crear la vista de infraestructura. Reutilizar
|
||||
«Conectado a» entre activos y ofrecer la consulta de conexiones desde ambos extremos.
|
||||
Mostrar en impresoras los equipos que las utilizan, derivados de perfiles y configuraciones.
|
||||
Separar estado técnico, reserva exclusiva y uso compartido al presentarlos.
|
||||
|
||||
Aceptación:
|
||||
|
||||
- Una impresora está conectada al switch 1 sin introducir ningún puerto.
|
||||
- A1, A2, A5 y P1 pueden utilizarla sin duplicar su activo ni sus reservas de puesto.
|
||||
- Dos perfiles de A1 no duplican A1 en la lista agregada.
|
||||
- Se distinguen configuraciones activas, pendientes de configurar/retirar e historial.
|
||||
- Un servidor, NAS o switch se administra sin crear un puesto.
|
||||
|
||||
### 2. Incorporar interfaces y direcciones
|
||||
|
||||
Alta y edición de interfaces relevantes para el activo, con sus direcciones IP,
|
||||
validación e historial. Captura manual; UniFi no es una dependencia para guardar.
|
||||
No exigir conocer un switch, AP, subred o puerto para registrar una interfaz o IP.
|
||||
|
||||
Aceptación:
|
||||
|
||||
- Una impresora conserva Ethernet y Wi-Fi con IP diferentes.
|
||||
- Puede guardarse una interfaz sin IP conocida o con varias IPv4/IPv6.
|
||||
- Un activo puede tener dos interfaces Ethernet diferenciadas por nombre.
|
||||
- Cambiar o retirar una IP conserva la identidad del activo y su historial.
|
||||
- Dos compañías pueden registrar la misma IP privada sin conflicto.
|
||||
- Guardar los mismos datos no añade revisiones; las referencias de otra compañía se rechazan.
|
||||
|
||||
### 3. Completar casos concretos de publicación y uso
|
||||
|
||||
Cuando se necesite: anfitrión de carpeta compartida, impresora USB compartida desde
|
||||
un PC, alojamiento de VM o uso de un activo a nivel de equipo sin perfil.
|
||||
Reutilizar las asociaciones existentes y evitar registros duplicados de un mismo uso.
|
||||
El modelo exhaustivo de colas o servicios requerirá su propio diseño.
|
||||
|
||||
### 4. Mejoras opcionales de consulta
|
||||
|
||||
Mapa sencillo entre equipos y acceso al controlador UniFi, si aportan utilidad.
|
||||
Una integración automática se evaluará como tarea separada con origen, reconciliación
|
||||
e historial; no implica ampliar el alcance a gestión de cableado, IPAM o monitorización.
|
||||
|
||||
## Decisiones de alcance para la siguiente entrega
|
||||
|
||||
1. El puesto mantiene su composición exclusiva; la infraestructura compartida tiene activos propios.
|
||||
2. Las conexiones son entre equipos, sin detalle de puertos ni cables.
|
||||
3. Cada activo puede tener varias interfaces y cada interfaz cero o varias IP.
|
||||
4. El uso de impresoras se consulta desde las configuraciones de sus perfiles, agrupado por equipo.
|
||||
5. Infraestructura será una vista del mismo inventario y sus asociaciones, sin duplicarlo.
|
||||
6. UniFi conserva el detalle de red; una integración no es necesaria para esta propuesta.
|
||||
7. La entrega comienza por conexiones/uso visibles e interfaces/IP. Los casos adicionales
|
||||
de recursos publicados se incorporan conforme se necesiten.
|
||||
|
||||
Este documento actualiza la propuesta funcional. No implementa cambios de código,
|
||||
no modifica datos y no cambia por sí mismo las reglas vigentes del dominio.
|
||||
@ -1044,3 +1044,22 @@ externas, sus datos propios y sus proyecciones de buzón o acceso dentro de una
|
||||
Tras borrar se limpia la selección afectada y se mantienen la búsqueda y la pestaña.
|
||||
Las altas, ediciones y cambios de estado o borrado reutilizan
|
||||
`resourceRelatedQueryKeys` para actualizar inventario y listados consumidores.
|
||||
|
||||
|
||||
### Infraestructura e interfaces de activos
|
||||
|
||||
`InfrastructurePage` presenta una vista del inventario con clasificación funcional y
|
||||
relaciones entre equipos. Reutiliza `EntityListHeader`, `EntityTable`, `EntityRowActions`
|
||||
y `AssetSelect`. `AssetInfrastructureSections` compone el apartado de interfaces y el
|
||||
contenido de uso aportado por la compañía. `AssetInfrastructurePanel` consulta datos e
|
||||
historial; `InfrastructureEditorDialog` encapsula el modal con `FormDialog layout="stable"`,
|
||||
React Hook Form, `FormBody`, `FormHelp` y `FormActions`. `InterfaceFields` administra los
|
||||
campos y direcciones de una interfaz, con etiquetas y errores locales.
|
||||
|
||||
`InventoryLookups.renderUsage` permite que Companies aporte `AssetPrinterUsage` sin
|
||||
introducir dependencias de Assets hacia Companies. El componente agrupa por equipo
|
||||
las configuraciones de impresión y muestra perfiles, estados e historial. Las consultas
|
||||
comparten claves y estados remotos existentes. Son componentes propios de sus módulos;
|
||||
las primitivas visuales genéricas siguen en @repo/rdx-ui y @repo/shadcn-ui.
|
||||
|
||||
Véase [Infraestructura informática](infraestructura-informatica.md).
|
||||
|
||||
129
docs/infraestructura-informatica.md
Normal file
129
docs/infraestructura-informatica.md
Normal file
@ -0,0 +1,129 @@
|
||||
# Infraestructura informática de la compañía
|
||||
|
||||
Implementación del núcleo acordado en la
|
||||
[auditoría de infraestructura](auditoria-de-infraestructura-informatica.md): conexiones
|
||||
entre equipos, interfaces/IP y consulta del uso documentado de impresoras.
|
||||
|
||||
## Acceso y clasificación
|
||||
|
||||
La compañía incorpora **Infraestructura** en su navegación. La ruta es
|
||||
`/companies/:companyId/infrastructure`. Es una vista del mismo inventario de activos;
|
||||
no crea otra copia de los equipos.
|
||||
|
||||
Las funciones disponibles son Red, Servidor, Almacenamiento y Periférico compartido.
|
||||
Un activo puede tener varias. Los routers, firewalls, switches y puntos de acceso de
|
||||
serie se presentan por defecto en Red; los servidores en Servidor y los NAS en
|
||||
Almacenamiento. El criterio usa `builtinKey`, publicado por el catálogo de activos,
|
||||
no el icono ni el nombre traducido. Las clasificaciones guardadas prevalecen sobre
|
||||
esos valores de serie. Las plantillas propias se clasifican expresamente.
|
||||
|
||||
**Añadir a infraestructura** reutiliza el selector de activos y abre el editor sobre
|
||||
el activo elegido. También se accede desde **Interfaces y direcciones** en la ficha
|
||||
lateral o en la ficha completa de cualquier activo. Marcar Periférico compartido no
|
||||
reserva el activo para un puesto ni instala configuraciones de impresión.
|
||||
|
||||
El listado admite búsqueda, filtros por función, columnas y ordenación del patrón
|
||||
común. Muestra interfaces/IP, estado técnico, puestos asignados y relaciones vigentes con
|
||||
enlaces a las fichas de ambos extremos. Para dar de alta un equipo nuevo se utiliza
|
||||
el alta existente de Activos, conservando sus plantillas, sedes e historial.
|
||||
|
||||
## Interfaces y direcciones
|
||||
|
||||
El editor tiene altura estable, scroll interno y ayuda junto al pie. Las interfaces
|
||||
admiten nombre, tipo Ethernet/Wi-Fi/Otra, MAC opcional, notas y cero o varias IP.
|
||||
Máximo 32 interfaces por activo y 32 direcciones por interfaz. Puede haber varias
|
||||
interfaces Ethernet con nombres distintos. Los nombres son únicos dentro del activo,
|
||||
ignorando mayúsculas y espacios exteriores.
|
||||
|
||||
Las direcciones admiten IPv4 e IPv6 sin máscara. Se normalizan las IPv6 y las MAC;
|
||||
no se admite repetir una dirección dentro de la misma interfaz. No hay unicidad
|
||||
global por dirección: dos compañías, o dos interfaces, pueden documentar la misma IP.
|
||||
No se infieren subredes, VLAN ni rutas. Una interfaz sin IP conocida se conserva.
|
||||
|
||||
Cada interfaz y dirección tiene identidad UUID v7, independiente de su nombre o IP.
|
||||
El editor conserva esas identidades al modificar datos. Repetir un guardado equivalente
|
||||
no añade revisión. Se rechazan identificadores de interfaces o direcciones que no
|
||||
pertenezcan a la configuración que se está editando.
|
||||
|
||||
Cada cambio guarda una instantánea con actor y fecha; la ficha permite consultar
|
||||
interfaces, direcciones, notas y funciones anteriores. Retirar una interfaz o una IP
|
||||
del formulario no borra sus instantáneas históricas.
|
||||
|
||||
Las IP/MAC que ya existían en características o notas permanecen intactas. Su traslado
|
||||
al nuevo apartado requiere revisar a qué interfaz corresponden; no hay una conversión
|
||||
que invente conexiones o elimine datos anteriores.
|
||||
|
||||
## Conexiones entre equipos
|
||||
|
||||
Se reutiliza [Relaciones entre activos](relaciones-entre-activos.md). **Conectado a**
|
||||
es suficiente para indicar impresora ↔ switch o NAS ↔ switch. Se consulta en las
|
||||
fichas de ambos extremos. No se requieren puertos numerados ni conexiones a nivel
|
||||
de interfaz para guardar esa relación. También se conservan Alojado en y los demás
|
||||
tipos de relación existentes.
|
||||
|
||||
UniFi conserva el detalle de puertos y cableado. Este núcleo no incorpora sincronización,
|
||||
descubrimiento, monitorización, VLAN o IPAM avanzado. Las relaciones representan
|
||||
información documentada, no una comprobación automática de conectividad actual.
|
||||
|
||||
## Dónde se utiliza una impresora
|
||||
|
||||
La ficha del activo en el espacio de compañía presenta **Dónde se utiliza**, a partir
|
||||
de `printer_configurations` y los perfiles existentes. La composición se inyecta desde
|
||||
Companies: Assets no importa repositorios, API ni componentes de Companies.
|
||||
|
||||
Se agrupa por equipo, con enlace a su ficha. Varios perfiles del mismo equipo producen
|
||||
una sola entrada de equipo. Dentro se muestran los perfiles, persona, conexión USB/red,
|
||||
estado, condición de predeterminada y avisos de revisión. Se conservan los estados
|
||||
Pendiente de configurar, Configurada y Pendiente de retirar; las configuraciones
|
||||
Retirada y No llegó a configurarse aparecen aparte como historial.
|
||||
|
||||
Si una referencia no se resuelve, se informa de ello; los errores de consulta no se
|
||||
presentan como ausencia de uso. Los datos se obtienen de las consultas compartidas de
|
||||
inventario, perfiles e impresoras y admiten reintento.
|
||||
|
||||
La consulta describe configuraciones registradas, no actividad de impresión observada.
|
||||
No mantiene una segunda lista editable de equipos usuarios. La reserva exclusiva del
|
||||
puesto no se modifica: una impresora compartida puede usarse en varios perfiles sin
|
||||
incorporarse a sus composiciones.
|
||||
|
||||
## Dominio, persistencia y HTTP
|
||||
|
||||
`AssetInfrastructure` es el agregado de la documentación de infraestructura de un
|
||||
activo. Contiene interfaces con direcciones identificadas y revisiones inmutables.
|
||||
Los valores normalizados comparan contenido semánticamente, sin serializar JSON.
|
||||
|
||||
La migración explícita se integra en el arranque del módulo de activos y es idempotente.
|
||||
Tablas relacionales:
|
||||
|
||||
- `asset_infrastructure`: identidad, compañía y activo, único por activo.
|
||||
- `asset_infrastructure_revisions`: actor, fecha y posición de cada instantánea.
|
||||
- `asset_infrastructure_roles`: funciones por revisión.
|
||||
- `asset_interface_snapshots`: valores e identidad de las interfaces por revisión.
|
||||
- `asset_interface_address_snapshots`: valores e identidad de direcciones por interfaz/revisión.
|
||||
|
||||
Las FK compuestas protegen la pertenencia activo/compañía y dirección/interfaz/revisión.
|
||||
Las revisiones se añaden; los datos actuales se obtienen de la última instantánea.
|
||||
El borrado definitivo permitido de un activo limpia también estos datos propios dentro
|
||||
de la misma transacción. Las asociaciones externas siguen bloqueando ese borrado.
|
||||
|
||||
`GET /api/asset-infrastructure/:companyId` devuelve configuraciones e historial.
|
||||
`POST /api/asset-infrastructure/:companyId/:assetId` guarda `{ values }` con funciones
|
||||
e interfaces. Los contratos Zod validan entrada y salida. La sesión aporta el actor;
|
||||
las rutas usan la autenticación común y las escrituras la protección HTTP del servidor.
|
||||
El repositorio valida la compañía, verifica la pertenencia del activo y bloquea la
|
||||
compañía durante la escritura para proteger el guardado concurrente.
|
||||
|
||||
## Validación y límites
|
||||
|
||||
Pruebas de normalización, límites, nombres/direcciones duplicados, identidad e historial,
|
||||
repetición sin cambios, clasificación independiente de iconos y uso agrupado por equipo.
|
||||
Las pruebas web comprueban es/en y referencias reales a fichas. Las pruebas HTTP usan
|
||||
Express real y un repositorio en memoria; comprueban sesión, validación y pertenencia.
|
||||
La prueba MariaDB utiliza tablas temporales en `metis_desk_test` para verificar escritura,
|
||||
rehidratación, historial y limpieza de los datos propios. La migración se ha aplicado
|
||||
y repetido en esa base para comprobar las nuevas restricciones.
|
||||
|
||||
Los anfitriones de recursos publicados, colas de impresión, usos sin perfil y mapas
|
||||
opcionales mantienen el carácter de ampliaciones posteriores de la propuesta. No son
|
||||
requisitos para registrar interfaces/IP, conexiones entre equipos o el uso de impresión
|
||||
ya documentado mediante perfiles.
|
||||
@ -169,3 +169,13 @@ El fallo de empresas se reprodujo ejecutando directamente el fichero: una prueba
|
||||
espera el texto «Esta persona no tiene acceso a Metis Desk», ausente en el render actual.
|
||||
No se ha modificado esa prueba ni el contenido de la ayuda para ocultar el fallo.
|
||||
Los módulos de tickets, intervenciones y sus plantillas permanecen [archivados](funcionalidades-archivadas.md).
|
||||
|
||||
|
||||
## Ampliación: interfaces e infraestructura (17 de octubre de 2026)
|
||||
|
||||
`InfrastructureEditorDialog`, en Assets, edita funciones e interfaces/IP de un activo.
|
||||
Vive en su propio fichero y utiliza `FormDialog layout="stable"`, cuerpo con scroll,
|
||||
ayuda inferior y acciones fijas. La selección de un activo existente reutiliza
|
||||
`AssetPickerDialog`, con sus reglas actuales de foco, selección y altura.
|
||||
Esta ampliación se añade al inventario anterior; no cambia el recuento histórico de
|
||||
la revisión del 15 de octubre. Véase [Infraestructura informática](infraestructura-informatica.md).
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/api/domain/workstation.test.ts src/api/domain/asset-relationship.test.ts src/web/catalog/shared/catalog.test.tsx src/web/assets/shared/inventory.test.tsx src/api/domain/support.test.ts src/web/support-connections/shared/support.test.tsx src/api/domain/brand.test.ts src/web/brands/shared/brands.test.tsx src/api/domain/template-sections.test.ts src/web/workstations/shared/helpers/workstation-creation.test.ts src/web/workstations/shared/workstation-name.test.ts src/web/workstations/rename/api/rename-workstation.test.ts src/web/assets/list/asset-workstation.test.tsx src/web/assets/select/asset-selection.test.tsx src/web/catalog-fields/select/catalog-field-selection.test.tsx"
|
||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/api/domain/infrastructure.test.ts src/api/domain/workstation.test.ts src/api/domain/asset-relationship.test.ts src/web/catalog/shared/catalog.test.tsx src/web/assets/shared/inventory.test.tsx src/web/infrastructure/shared/infrastructure.test.tsx src/api/domain/support.test.ts src/web/support-connections/shared/support.test.tsx src/api/domain/brand.test.ts src/web/brands/shared/brands.test.tsx src/api/domain/template-sections.test.ts src/web/workstations/shared/helpers/workstation-creation.test.ts src/web/workstations/shared/workstation-name.test.ts src/web/workstations/rename/api/rename-workstation.test.ts src/web/assets/list/asset-workstation.test.tsx src/web/assets/select/asset-selection.test.tsx src/web/catalog-fields/select/catalog-field-selection.test.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "5.2.2",
|
||||
|
||||
@ -13,6 +13,7 @@ export function buildCatalogResponse(
|
||||
id: item.id.toPrimitive(),
|
||||
companyId: item.companyId.getOrUndefined()?.toPrimitive() ?? null,
|
||||
builtin: item.stockKey.getOrUndefined() !== undefined,
|
||||
builtinKey: item.stockKey.getOrUndefined() ?? null,
|
||||
...(item.stockKey.getOrUndefined() === "field:brand"
|
||||
? { referenceCatalog: "brands" as const }
|
||||
: {}),
|
||||
|
||||
@ -0,0 +1,21 @@
|
||||
import type { InfrastructureDTO } from "../../common/infrastructure";
|
||||
import type { AssetInfrastructure } from "../domain/asset-infrastructure.aggregate";
|
||||
|
||||
export function buildInfrastructureResponse(
|
||||
item: AssetInfrastructure,
|
||||
actors: ReadonlyMap<string, string>
|
||||
): InfrastructureDTO {
|
||||
return {
|
||||
id: item.id.toPrimitive(),
|
||||
companyId: item.companyId.toPrimitive(),
|
||||
assetId: item.assetId.toPrimitive(),
|
||||
values: item.values.toPrimitive(),
|
||||
history: item.history.map((revision) => ({
|
||||
id: revision.id.toPrimitive(),
|
||||
values: revision.values.toPrimitive(),
|
||||
actorId: revision.actorId.toPrimitive(),
|
||||
actorName: actors.get(revision.actorId.toPrimitive()) ?? "—",
|
||||
recordedAt: revision.recordedAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import type { Result } from "@repo/rdx-utils";
|
||||
|
||||
import type { AssetCatalogError } from "../domain/asset-definition";
|
||||
import type { AssetInfrastructure } from "../domain/asset-infrastructure.aggregate";
|
||||
|
||||
export interface IInfrastructureRepository {
|
||||
get(
|
||||
company: UniqueID
|
||||
): Promise<
|
||||
Result<
|
||||
{ items: readonly AssetInfrastructure[]; actorNames: ReadonlyMap<string, string> },
|
||||
AssetCatalogError
|
||||
>
|
||||
>;
|
||||
save(
|
||||
company: UniqueID,
|
||||
asset: UniqueID,
|
||||
change: (
|
||||
previous: AssetInfrastructure | undefined
|
||||
) => Result<AssetInfrastructure, AssetCatalogError>
|
||||
): Promise<
|
||||
Result<
|
||||
{ item: AssetInfrastructure; actorNames: ReadonlyMap<string, string> },
|
||||
AssetCatalogError
|
||||
>
|
||||
>;
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import { AssetCatalogError } from "../domain/asset-definition";
|
||||
import { AssetInfrastructure } from "../domain/asset-infrastructure.aggregate";
|
||||
import type { InfrastructureInput } from "../domain/infrastructure-values";
|
||||
import { InfrastructureValues } from "../domain/value-objects/infrastructure-values.vo";
|
||||
|
||||
import type { IInfrastructureRepository } from "./infrastructure.repository";
|
||||
|
||||
export class InfrastructureUseCases {
|
||||
constructor(private readonly repository: IInfrastructureRepository) {}
|
||||
|
||||
get(companyId: string) {
|
||||
const company = UniqueID.create(companyId);
|
||||
|
||||
return company.isSuccess
|
||||
? this.repository.get(company.data)
|
||||
: Promise.resolve(Result.fail(new AssetCatalogError("INVALID_REQUEST")));
|
||||
}
|
||||
|
||||
save(companyId: string, assetId: string, input: InfrastructureInput, actorId: string) {
|
||||
const company = UniqueID.create(companyId);
|
||||
const asset = UniqueID.create(assetId);
|
||||
const actor = UniqueID.create(actorId);
|
||||
|
||||
if (!(company.isSuccess && asset.isSuccess && actor.isSuccess)) {
|
||||
return Promise.resolve(Result.fail(new AssetCatalogError("INVALID_REQUEST")));
|
||||
}
|
||||
|
||||
return this.repository.save(company.data, asset.data, (previous) => {
|
||||
const values = InfrastructureValues.create(input, previous?.values);
|
||||
|
||||
if (!values.isSuccess) {
|
||||
return Result.fail(values.error);
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
|
||||
return previous
|
||||
? previous.update(values.data, actor.data, now)
|
||||
: AssetInfrastructure.create(company.data, asset.data, values.data, actor.data, now);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,87 @@
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import { AggregateRoot } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { AssetCatalogError } from "./asset-definition";
|
||||
import { InfrastructureRevision } from "./infrastructure-revision.entity";
|
||||
import type { InfrastructureValues } from "./value-objects/infrastructure-values.vo";
|
||||
|
||||
type InfrastructureProps = Readonly<{
|
||||
companyId: UniqueID;
|
||||
assetId: UniqueID;
|
||||
history: readonly InfrastructureRevision[];
|
||||
}>;
|
||||
|
||||
export class AssetInfrastructure extends AggregateRoot<InfrastructureProps> {
|
||||
private constructor(props: InfrastructureProps, id?: UniqueID) {
|
||||
super(Object.freeze({ ...props, history: Object.freeze([...props.history]) }), id);
|
||||
}
|
||||
|
||||
static create(
|
||||
companyId: UniqueID,
|
||||
assetId: UniqueID,
|
||||
values: InfrastructureValues,
|
||||
actorId: UniqueID,
|
||||
recordedAt: string
|
||||
) {
|
||||
if (!z.iso.datetime().safeParse(recordedAt).success) {
|
||||
return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
|
||||
}
|
||||
|
||||
return Result.ok(
|
||||
new AssetInfrastructure({
|
||||
companyId,
|
||||
assetId,
|
||||
history: [InfrastructureRevision.create({ values, actorId, recordedAt })],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
static rehydrate(id: UniqueID, props: InfrastructureProps) {
|
||||
return new AssetInfrastructure(props, id);
|
||||
}
|
||||
|
||||
get companyId() {
|
||||
return this.props.companyId;
|
||||
}
|
||||
|
||||
get assetId() {
|
||||
return this.props.assetId;
|
||||
}
|
||||
|
||||
get history() {
|
||||
return this.props.history;
|
||||
}
|
||||
|
||||
get values() {
|
||||
const latest = this.history.at(-1);
|
||||
|
||||
if (!latest) {
|
||||
throw new Error("Infraestructura sin revisión.");
|
||||
}
|
||||
|
||||
return latest.values;
|
||||
}
|
||||
|
||||
update(values: InfrastructureValues, actorId: UniqueID, recordedAt: string) {
|
||||
if (!z.iso.datetime().safeParse(recordedAt).success) {
|
||||
return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
|
||||
}
|
||||
|
||||
return Result.ok(
|
||||
this.values.equals(values)
|
||||
? this
|
||||
: new AssetInfrastructure(
|
||||
{
|
||||
...this.props,
|
||||
history: [
|
||||
...this.history,
|
||||
InfrastructureRevision.create({ values, actorId, recordedAt }),
|
||||
],
|
||||
},
|
||||
this.id
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import { DomainEntity } from "@repo/rdx-ddd";
|
||||
|
||||
import type { InfrastructureData } from "./infrastructure-values";
|
||||
import type { InterfaceAddress } from "./interface-address.entity";
|
||||
|
||||
type InterfaceProps = Readonly<{
|
||||
name: string;
|
||||
kind: InfrastructureData["interfaces"][number]["kind"];
|
||||
mac: string;
|
||||
notes: string;
|
||||
addresses: readonly InterfaceAddress[];
|
||||
}>;
|
||||
|
||||
export class InfrastructureInterface extends DomainEntity<InterfaceProps> {
|
||||
static rehydrate(id: UniqueID, props: InterfaceProps) {
|
||||
return new InfrastructureInterface(
|
||||
Object.freeze({ ...props, addresses: Object.freeze([...props.addresses]) }),
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
toPrimitive(): InfrastructureData["interfaces"][number] {
|
||||
return {
|
||||
id: this.id.toPrimitive(),
|
||||
name: this.props.name,
|
||||
kind: this.props.kind,
|
||||
mac: this.props.mac,
|
||||
notes: this.props.notes,
|
||||
addresses: this.props.addresses.map((item) => ({
|
||||
id: item.id.toPrimitive(),
|
||||
address: item.address,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import { DomainEntity } from "@repo/rdx-ddd";
|
||||
|
||||
import type { InfrastructureValues } from "./value-objects/infrastructure-values.vo";
|
||||
|
||||
type RevisionProps = Readonly<{
|
||||
values: InfrastructureValues;
|
||||
actorId: UniqueID;
|
||||
recordedAt: string;
|
||||
}>;
|
||||
|
||||
export class InfrastructureRevision extends DomainEntity<RevisionProps> {
|
||||
static create(props: RevisionProps) {
|
||||
return new InfrastructureRevision(Object.freeze(props));
|
||||
}
|
||||
|
||||
static rehydrate(id: UniqueID, props: RevisionProps) {
|
||||
return new InfrastructureRevision(Object.freeze(props), id);
|
||||
}
|
||||
|
||||
get values() {
|
||||
return this.props.values;
|
||||
}
|
||||
|
||||
get actorId() {
|
||||
return this.props.actorId;
|
||||
}
|
||||
|
||||
get recordedAt() {
|
||||
return this.props.recordedAt;
|
||||
}
|
||||
}
|
||||
51
modules/assets/src/api/domain/infrastructure-values.ts
Normal file
51
modules/assets/src/api/domain/infrastructure-values.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
export const InfrastructureRoleSchema = z.enum([
|
||||
"network",
|
||||
"server",
|
||||
"storage",
|
||||
"shared_peripheral",
|
||||
]);
|
||||
|
||||
export const InterfaceAddressInputSchema = z.strictObject({
|
||||
id: z.uuid().optional(),
|
||||
address: z
|
||||
.union([z.ipv4(), z.ipv6()])
|
||||
.transform((value) =>
|
||||
value.includes(":") ? new URL(`http://[${value}]`).hostname.slice(1, -1) : value
|
||||
),
|
||||
});
|
||||
|
||||
export const InfrastructureInterfaceInputSchema = z.strictObject({
|
||||
id: z.uuid().optional(),
|
||||
name: z.string().trim().min(1).max(100),
|
||||
kind: z.enum(["ethernet", "wifi", "other"]),
|
||||
mac: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(17)
|
||||
.refine((value) => !value || /^(?:[0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test(value))
|
||||
.transform((value) => value.replaceAll("-", ":").toUpperCase()),
|
||||
notes: z.string().trim().max(2000),
|
||||
addresses: z.array(InterfaceAddressInputSchema).max(32),
|
||||
});
|
||||
|
||||
export const InfrastructureInputSchema = z.strictObject({
|
||||
roles: z.array(InfrastructureRoleSchema).max(4),
|
||||
interfaces: z.array(InfrastructureInterfaceInputSchema).max(32),
|
||||
});
|
||||
|
||||
export const InfrastructureValuesSchema = InfrastructureInputSchema.extend({
|
||||
interfaces: z
|
||||
.array(
|
||||
InfrastructureInterfaceInputSchema.extend({
|
||||
id: z.uuid(),
|
||||
addresses: z.array(InterfaceAddressInputSchema.extend({ id: z.uuid() })).max(32),
|
||||
})
|
||||
)
|
||||
.max(32),
|
||||
});
|
||||
|
||||
export type InfrastructureInput = z.infer<typeof InfrastructureInputSchema>;
|
||||
|
||||
export type InfrastructureData = z.infer<typeof InfrastructureValuesSchema>;
|
||||
177
modules/assets/src/api/domain/infrastructure.test.ts
Normal file
177
modules/assets/src/api/domain/infrastructure.test.ts
Normal file
@ -0,0 +1,177 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
|
||||
import { AssetInfrastructure } from "./asset-infrastructure.aggregate";
|
||||
import type { InfrastructureInput } from "./infrastructure-values";
|
||||
import { InfrastructureValues } from "./value-objects/infrastructure-values.vo";
|
||||
|
||||
function input(): InfrastructureInput {
|
||||
return {
|
||||
roles: ["shared_peripheral", "network"],
|
||||
interfaces: [
|
||||
{
|
||||
name: "Ethernet",
|
||||
kind: "ethernet",
|
||||
mac: "aa-bb-cc-dd-ee-ff",
|
||||
notes: " Red local ",
|
||||
addresses: [{ address: "192.168.5.201" }, { address: "2001:0DB8:0:0::1" }],
|
||||
},
|
||||
{ name: "Wi-Fi", kind: "wifi", mac: "", notes: "", addresses: [] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
test("infraestructura: interfaces independientes, varias IP, normalización y UUID v7", () => {
|
||||
const source = input();
|
||||
const result = InfrastructureValues.create(source);
|
||||
|
||||
assert.ok(result.isSuccess);
|
||||
const values = result.data.toPrimitive();
|
||||
|
||||
assert.equal(values.interfaces[0]?.mac, "AA:BB:CC:DD:EE:FF");
|
||||
assert.equal(values.interfaces[0]?.notes, "Red local");
|
||||
assert.equal(values.interfaces[0]?.addresses[1]?.address, "2001:db8::1");
|
||||
assert.deepEqual(values.interfaces[1]?.addresses, []);
|
||||
for (const item of values.interfaces) {
|
||||
assert.equal(item.id[14], "7");
|
||||
for (const address of item.addresses) {
|
||||
assert.equal(address.id[14], "7");
|
||||
}
|
||||
}
|
||||
assert.equal(source.interfaces[0]?.mac, "aa-bb-cc-dd-ee-ff");
|
||||
values.roles.push("server");
|
||||
assert.equal(result.data.roles.includes("server"), false);
|
||||
});
|
||||
|
||||
test("infraestructura: mismo contenido no cambia identidades ni añade revisiones", () => {
|
||||
const first = InfrastructureValues.create(input());
|
||||
|
||||
assert.ok(first.isSuccess);
|
||||
const actor = UniqueID.generateNewID();
|
||||
const created = AssetInfrastructure.create(
|
||||
UniqueID.generateNewID(),
|
||||
UniqueID.generateNewID(),
|
||||
first.data,
|
||||
actor,
|
||||
"2026-10-17T10:00:00.000Z"
|
||||
);
|
||||
|
||||
assert.ok(created.isSuccess);
|
||||
const same = InfrastructureValues.create(input(), first.data);
|
||||
|
||||
assert.ok(same.isSuccess);
|
||||
assert.deepEqual(same.data.toPrimitive(), first.data.toPrimitive());
|
||||
const unchanged = created.data.update(same.data, actor, "2026-10-17T11:00:00.000Z");
|
||||
|
||||
assert.ok(unchanged.isSuccess);
|
||||
assert.equal(unchanged.data, created.data);
|
||||
const draft = first.data.toPrimitive();
|
||||
const eth = draft.interfaces[0];
|
||||
|
||||
assert.ok(eth);
|
||||
eth.name = "LAN principal";
|
||||
eth.addresses = [
|
||||
{
|
||||
address: "192.168.5.202",
|
||||
id: eth.addresses[0]?.id ?? UniqueID.generateNewID().toPrimitive(),
|
||||
},
|
||||
];
|
||||
const changedValues = InfrastructureValues.create(draft, first.data);
|
||||
|
||||
assert.ok(changedValues.isSuccess);
|
||||
const changed = created.data.update(changedValues.data, actor, "2026-10-17T12:00:00.000Z");
|
||||
|
||||
assert.ok(changed.isSuccess);
|
||||
assert.ok(changed.data.id.equals(created.data.id));
|
||||
assert.equal(changed.data.history.length, 2);
|
||||
assert.equal(
|
||||
changed.data.values.toPrimitive().interfaces[0]?.id,
|
||||
first.data.toPrimitive().interfaces[0]?.id
|
||||
);
|
||||
assert.equal(
|
||||
changed.data.history[0]?.values.toPrimitive().interfaces[0]?.addresses[0]?.address,
|
||||
"192.168.5.201"
|
||||
);
|
||||
});
|
||||
|
||||
test("infraestructura: rechaza nombres/IP duplicados, valores inválidos e identidades ajenas", () => {
|
||||
const base = input();
|
||||
const iface = base.interfaces[0];
|
||||
|
||||
assert.ok(iface);
|
||||
const invalid: InfrastructureInput[] = [
|
||||
{ roles: ["network", "network"], interfaces: [] },
|
||||
{ roles: [], interfaces: [iface, { ...iface, name: " ETHERNET " }] },
|
||||
{
|
||||
roles: [],
|
||||
interfaces: [
|
||||
{ ...iface, addresses: [{ address: "2001:db8::1" }, { address: "2001:0db8:0:0::1" }] },
|
||||
],
|
||||
},
|
||||
{ roles: [], interfaces: [{ ...iface, addresses: [{ address: "192.168.5.201/24" }] }] },
|
||||
{ roles: [], interfaces: [{ ...iface, addresses: [{ address: "999.1.1.1" }] }] },
|
||||
{ roles: [], interfaces: [{ ...iface, mac: "not-a-mac" }] },
|
||||
{ roles: [], interfaces: [{ ...iface, id: UniqueID.generateNewID().toPrimitive() }] },
|
||||
{
|
||||
roles: [],
|
||||
interfaces: [
|
||||
{
|
||||
...iface,
|
||||
addresses: [{ address: "192.168.5.201", id: UniqueID.generateNewID().toPrimitive() }],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
for (const value of invalid) {
|
||||
assert.equal(InfrastructureValues.create(value).isSuccess, false);
|
||||
}
|
||||
const original = InfrastructureValues.create(base);
|
||||
|
||||
assert.ok(original.isSuccess);
|
||||
const stolen = original.data.toPrimitive();
|
||||
const address = stolen.interfaces[0]?.addresses[0];
|
||||
const wifi = stolen.interfaces[1];
|
||||
|
||||
assert.ok(address && wifi);
|
||||
wifi.addresses.push(address);
|
||||
assert.equal(InfrastructureValues.create(stolen, original.data).isSuccess, false);
|
||||
});
|
||||
|
||||
test("infraestructura: límites y direcciones repetidas entre interfaces o compañías permitidas", () => {
|
||||
const interfaces = Array.from({ length: 32 }, (_, index) => ({
|
||||
name: `LAN ${index}`,
|
||||
kind: "ethernet" as const,
|
||||
mac: "",
|
||||
notes: "",
|
||||
addresses: [{ address: "192.168.5.201" }],
|
||||
}));
|
||||
|
||||
assert.ok(InfrastructureValues.create({ roles: [], interfaces }).isSuccess);
|
||||
assert.equal(
|
||||
InfrastructureValues.create({
|
||||
roles: [],
|
||||
interfaces: [
|
||||
...interfaces,
|
||||
{ name: "LAN extra", kind: "other", mac: "", notes: "", addresses: [] },
|
||||
],
|
||||
}).isSuccess,
|
||||
false
|
||||
);
|
||||
assert.ok(InfrastructureValues.create({ roles: [], interfaces: [] }).isSuccess);
|
||||
const other = InfrastructureValues.create(input());
|
||||
|
||||
assert.ok(other.isSuccess);
|
||||
assert.equal(
|
||||
AssetInfrastructure.create(
|
||||
UniqueID.generateNewID(),
|
||||
UniqueID.generateNewID(),
|
||||
other.data,
|
||||
UniqueID.generateNewID(),
|
||||
"not-a-date"
|
||||
).isSuccess,
|
||||
false
|
||||
);
|
||||
});
|
||||
12
modules/assets/src/api/domain/interface-address.entity.ts
Normal file
12
modules/assets/src/api/domain/interface-address.entity.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { DomainEntity, type UniqueID } from "@repo/rdx-ddd";
|
||||
|
||||
/** Identidad estable de una dirección dentro de la interfaz y sus revisiones. */
|
||||
export class InterfaceAddress extends DomainEntity<{ address: string }> {
|
||||
static rehydrate(id: UniqueID, address: string) {
|
||||
return new InterfaceAddress({ address }, id);
|
||||
}
|
||||
|
||||
get address() {
|
||||
return this.props.address;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,153 @@
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
|
||||
import { AssetCatalogError } from "../asset-definition";
|
||||
import { InfrastructureInterface } from "../infrastructure-interface.entity";
|
||||
import type { InfrastructureData, InfrastructureInput } from "../infrastructure-values";
|
||||
import { InfrastructureInputSchema, InfrastructureValuesSchema } from "../infrastructure-values";
|
||||
import { InterfaceAddress } from "../interface-address.entity";
|
||||
|
||||
export function infrastructureIdentity(value: string) {
|
||||
const id = UniqueID.create(value);
|
||||
|
||||
if (!id.isSuccess) {
|
||||
throw new Error("Identidad de infraestructura persistida inválida.");
|
||||
}
|
||||
|
||||
return id.data;
|
||||
}
|
||||
|
||||
export class InfrastructureValues {
|
||||
private constructor(
|
||||
readonly roles: readonly InfrastructureData["roles"][number][],
|
||||
readonly interfaces: readonly InfrastructureInterface[]
|
||||
) {
|
||||
Object.freeze(this);
|
||||
}
|
||||
|
||||
static create(input: InfrastructureInput, previous?: InfrastructureValues) {
|
||||
const parsed = InfrastructureInputSchema.safeParse(input);
|
||||
|
||||
if (!parsed.success) {
|
||||
return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
|
||||
}
|
||||
const old = previous?.toPrimitive().interfaces ?? [];
|
||||
const interfaces = parsed.data.interfaces.map((item) => {
|
||||
const existing = item.id
|
||||
? old.find((entry) => entry.id === item.id)
|
||||
: old.find(
|
||||
(entry) => entry.name.toLocaleLowerCase("es") === item.name.toLocaleLowerCase("es")
|
||||
);
|
||||
|
||||
return {
|
||||
...item,
|
||||
id: item.id ?? existing?.id ?? UniqueID.generateNewID().toPrimitive(),
|
||||
addresses: item.addresses.map((address) => ({
|
||||
...address,
|
||||
id:
|
||||
address.id ??
|
||||
existing?.addresses.find((entry) => entry.address === address.address)?.id ??
|
||||
UniqueID.generateNewID().toPrimitive(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
if (
|
||||
interfaces.some((item) => {
|
||||
const existing = old.find((entry) => entry.id === item.id);
|
||||
|
||||
return (
|
||||
(parsed.data.interfaces.some((entry) => entry.id === item.id) && !existing) ||
|
||||
item.addresses.some(
|
||||
(address) =>
|
||||
parsed.data.interfaces
|
||||
.flatMap((entry) => entry.addresses)
|
||||
.some((entry) => entry.id === address.id) &&
|
||||
!existing?.addresses.some((entry) => entry.id === address.id)
|
||||
)
|
||||
);
|
||||
})
|
||||
) {
|
||||
return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
|
||||
}
|
||||
|
||||
return InfrastructureValues.reconstruct({ roles: parsed.data.roles, interfaces });
|
||||
}
|
||||
|
||||
static reconstruct(data: InfrastructureData) {
|
||||
const parsed = InfrastructureValuesSchema.safeParse(data);
|
||||
|
||||
if (!parsed.success) {
|
||||
return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
|
||||
}
|
||||
const values = parsed.data;
|
||||
const names = values.interfaces.map((item) => item.name.toLocaleLowerCase("es"));
|
||||
const ids = values.interfaces.flatMap((item) => [
|
||||
item.id,
|
||||
...item.addresses.map((address) => address.id),
|
||||
]);
|
||||
|
||||
if (
|
||||
new Set(names).size !== names.length ||
|
||||
new Set(ids).size !== ids.length ||
|
||||
new Set(values.roles).size !== values.roles.length ||
|
||||
values.interfaces.some(
|
||||
(item) =>
|
||||
new Set(item.addresses.map((address) => address.address)).size !== item.addresses.length
|
||||
)
|
||||
) {
|
||||
return Result.fail(new AssetCatalogError("INVALID_REQUEST"));
|
||||
}
|
||||
|
||||
return Result.ok(
|
||||
new InfrastructureValues(
|
||||
Object.freeze([...values.roles].sort()),
|
||||
Object.freeze(
|
||||
values.interfaces.map((item) =>
|
||||
InfrastructureInterface.rehydrate(infrastructureIdentity(item.id), {
|
||||
...item,
|
||||
addresses: item.addresses.map((address) =>
|
||||
InterfaceAddress.rehydrate(infrastructureIdentity(address.id), address.address)
|
||||
),
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
toPrimitive(): InfrastructureData {
|
||||
return {
|
||||
roles: [...this.roles],
|
||||
interfaces: this.interfaces.map((item) => item.toPrimitive()),
|
||||
};
|
||||
}
|
||||
|
||||
equals(other: InfrastructureValues) {
|
||||
const a = this.toPrimitive();
|
||||
const b = other.toPrimitive();
|
||||
|
||||
return (
|
||||
a.roles.join() === b.roles.join() &&
|
||||
a.interfaces.length === b.interfaces.length &&
|
||||
a.interfaces.every((item, index) => {
|
||||
const next = b.interfaces[index];
|
||||
|
||||
return (
|
||||
next &&
|
||||
item.id === next.id &&
|
||||
item.name === next.name &&
|
||||
item.kind === next.kind &&
|
||||
item.mac === next.mac &&
|
||||
item.notes === next.notes &&
|
||||
item.addresses.length === next.addresses.length &&
|
||||
item.addresses.every(
|
||||
(address, position) =>
|
||||
address.id === next.addresses[position]?.id &&
|
||||
address.address === next.addresses[position]?.address
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
import type { Application, RequestHandler, Response } from "express";
|
||||
import { Router } from "express";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
InfrastructureItemParamsSchema,
|
||||
InfrastructureParamsSchema,
|
||||
SaveInfrastructureRequestSchema,
|
||||
} from "../../../common/infrastructure";
|
||||
import type { InfrastructureUseCases } from "../../application/infrastructure.use-cases";
|
||||
import { buildInfrastructureResponse } from "../../application/infrastructure-response.builder";
|
||||
|
||||
function fail(response: Response, code: string) {
|
||||
response
|
||||
.status(code.endsWith("NOT_FOUND") ? 404 : 400)
|
||||
.json({ code, requestId: response.getHeader("x-request-id") });
|
||||
}
|
||||
|
||||
export function registerInfrastructureRoutes(
|
||||
app: Application,
|
||||
service: InfrastructureUseCases,
|
||||
authenticate: RequestHandler
|
||||
) {
|
||||
const router = Router();
|
||||
|
||||
router.get("/:companyId", (request, response, next) => {
|
||||
const params = InfrastructureParamsSchema.safeParse(request.params);
|
||||
|
||||
if (!(params.success && z.strictObject({}).safeParse(request.query).success)) {
|
||||
fail(response, "INVALID_REQUEST");
|
||||
|
||||
return;
|
||||
}
|
||||
service
|
||||
.get(params.data.companyId)
|
||||
.then((result) => {
|
||||
if (!result.isSuccess) {
|
||||
fail(response, result.error.code);
|
||||
|
||||
return;
|
||||
}
|
||||
response.json({
|
||||
items: result.data.items.map((item) =>
|
||||
buildInfrastructureResponse(item, result.data.actorNames)
|
||||
),
|
||||
});
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
router.post("/:companyId/:assetId", (request, response, next) => {
|
||||
const params = InfrastructureItemParamsSchema.safeParse(request.params);
|
||||
const body = SaveInfrastructureRequestSchema.safeParse(request.body);
|
||||
const actor = z
|
||||
.object({ userId: z.uuid() })
|
||||
.safeParse("session" in request ? request.session : undefined);
|
||||
|
||||
if (
|
||||
!(
|
||||
params.success &&
|
||||
body.success &&
|
||||
actor.success &&
|
||||
z.strictObject({}).safeParse(request.query).success
|
||||
)
|
||||
) {
|
||||
fail(response, "INVALID_REQUEST");
|
||||
|
||||
return;
|
||||
}
|
||||
service
|
||||
.save(params.data.companyId, params.data.assetId, body.data.values, actor.data.userId)
|
||||
.then((result) => {
|
||||
if (!result.isSuccess) {
|
||||
fail(response, result.error.code);
|
||||
|
||||
return;
|
||||
}
|
||||
response.json({
|
||||
item: buildInfrastructureResponse(result.data.item, result.data.actorNames),
|
||||
});
|
||||
})
|
||||
.catch(next);
|
||||
});
|
||||
app.use("/api/asset-infrastructure", authenticate, router);
|
||||
}
|
||||
@ -0,0 +1,91 @@
|
||||
import type { Sequelize } from "sequelize";
|
||||
import { DataTypes, QueryTypes } from "sequelize";
|
||||
|
||||
export async function migrateInfrastructure(database: Sequelize) {
|
||||
const query = database.getQueryInterface();
|
||||
const tables = await database.query<{ name: string }>(
|
||||
"SELECT TABLE_NAME AS name FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE()",
|
||||
{ type: QueryTypes.SELECT }
|
||||
);
|
||||
const id = { type: DataTypes.UUID, primaryKey: true };
|
||||
|
||||
const fk = (table: string) => ({
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: { model: table, key: "id" },
|
||||
onDelete: "RESTRICT",
|
||||
});
|
||||
|
||||
const revision = () => ({ ...fk("asset_infrastructure_revisions"), primaryKey: true });
|
||||
|
||||
const definitions = [
|
||||
[
|
||||
"asset_infrastructure",
|
||||
{ id, companyId: fk("companies"), assetId: { ...fk("assets"), unique: true } },
|
||||
],
|
||||
[
|
||||
"asset_infrastructure_revisions",
|
||||
{
|
||||
id,
|
||||
infrastructureId: fk("asset_infrastructure"),
|
||||
actorId: fk("users"),
|
||||
recordedAt: { type: DataTypes.STRING(30), allowNull: false },
|
||||
position: { type: DataTypes.INTEGER, allowNull: false },
|
||||
},
|
||||
],
|
||||
[
|
||||
"asset_infrastructure_roles",
|
||||
{ revisionId: revision(), role: { type: DataTypes.STRING(30), primaryKey: true } },
|
||||
],
|
||||
[
|
||||
"asset_interface_snapshots",
|
||||
{
|
||||
revisionId: revision(),
|
||||
id,
|
||||
name: { type: DataTypes.STRING(100), allowNull: false },
|
||||
kind: { type: DataTypes.STRING(12), allowNull: false },
|
||||
mac: { type: DataTypes.STRING(17), allowNull: false },
|
||||
notes: { type: DataTypes.TEXT, allowNull: false },
|
||||
position: { type: DataTypes.INTEGER, allowNull: false },
|
||||
},
|
||||
],
|
||||
[
|
||||
"asset_interface_address_snapshots",
|
||||
{
|
||||
revisionId: revision(),
|
||||
id,
|
||||
interfaceId: { type: DataTypes.UUID, allowNull: false },
|
||||
address: { type: DataTypes.STRING(45), allowNull: false },
|
||||
position: { type: DataTypes.INTEGER, allowNull: false },
|
||||
},
|
||||
],
|
||||
] as const;
|
||||
|
||||
for (const [table, columns] of definitions) {
|
||||
if (!tables.some((row) => row.name === table)) {
|
||||
await query.createTable(table, columns);
|
||||
}
|
||||
}
|
||||
const constraints = await database.query<{ name: string }>(
|
||||
"SELECT CONSTRAINT_NAME AS name FROM information_schema.TABLE_CONSTRAINTS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN ('asset_infrastructure','asset_interface_address_snapshots','asset_infrastructure_revisions')",
|
||||
{ type: QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
if (!constraints.some((row) => row.name === "infrastructure_asset_company")) {
|
||||
await database.query(
|
||||
"ALTER TABLE asset_infrastructure ADD CONSTRAINT infrastructure_asset_company FOREIGN KEY (companyId,assetId) REFERENCES assets(companyId,id)"
|
||||
);
|
||||
}
|
||||
if (!constraints.some((row) => row.name === "interface_address_owner")) {
|
||||
await database.query(
|
||||
"ALTER TABLE asset_interface_address_snapshots ADD CONSTRAINT interface_address_owner FOREIGN KEY (revisionId,interfaceId) REFERENCES asset_interface_snapshots(revisionId,id)"
|
||||
);
|
||||
}
|
||||
if (!constraints.some((row) => row.name === "infrastructure_revision_position")) {
|
||||
await query.addConstraint("asset_infrastructure_revisions", {
|
||||
fields: ["infrastructureId", "position"],
|
||||
type: "unique",
|
||||
name: "infrastructure_revision_position",
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,100 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { AssetInfrastructure } from "../../domain/asset-infrastructure.aggregate";
|
||||
import { InfrastructureRevision } from "../../domain/infrastructure-revision.entity";
|
||||
import {
|
||||
InfrastructureRoleSchema,
|
||||
InfrastructureValuesSchema,
|
||||
} from "../../domain/infrastructure-values";
|
||||
import {
|
||||
InfrastructureValues,
|
||||
infrastructureIdentity,
|
||||
} from "../../domain/value-objects/infrastructure-values.vo";
|
||||
|
||||
export const InfrastructureRootRowSchema = z.object({
|
||||
id: z.uuid(),
|
||||
companyId: z.uuid(),
|
||||
assetId: z.uuid(),
|
||||
});
|
||||
|
||||
export const InfrastructureRevisionRowSchema = z.object({
|
||||
id: z.uuid(),
|
||||
infrastructureId: z.uuid(),
|
||||
actorId: z.uuid(),
|
||||
recordedAt: z.iso.datetime(),
|
||||
position: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
export const InfrastructureRoleRowSchema = z.object({
|
||||
revisionId: z.uuid(),
|
||||
role: InfrastructureRoleSchema,
|
||||
});
|
||||
|
||||
export const InterfaceRowSchema = InfrastructureValuesSchema.shape.interfaces.element
|
||||
.omit({ addresses: true })
|
||||
.extend({ revisionId: z.uuid(), position: z.number().int().nonnegative() });
|
||||
|
||||
export const AddressRowSchema =
|
||||
InfrastructureValuesSchema.shape.interfaces.element.shape.addresses.element.extend({
|
||||
revisionId: z.uuid(),
|
||||
interfaceId: z.uuid(),
|
||||
position: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
export class InfrastructurePersistenceMapper {
|
||||
toDomain(
|
||||
root: z.infer<typeof InfrastructureRootRowSchema>,
|
||||
revisions: z.infer<typeof InfrastructureRevisionRowSchema>[],
|
||||
roles: z.infer<typeof InfrastructureRoleRowSchema>[],
|
||||
interfaces: z.infer<typeof InterfaceRowSchema>[],
|
||||
addresses: z.infer<typeof AddressRowSchema>[]
|
||||
) {
|
||||
const history = revisions
|
||||
.filter((row) => row.infrastructureId === root.id)
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((row, index) => {
|
||||
if (row.position !== index) {
|
||||
throw new Error("Secuencia de infraestructura inconsistente.");
|
||||
}
|
||||
const values = InfrastructureValues.reconstruct({
|
||||
roles: roles.filter((role) => role.revisionId === row.id).map((role) => role.role),
|
||||
interfaces: interfaces
|
||||
.filter((item) => item.revisionId === row.id)
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
kind: item.kind,
|
||||
mac: item.mac,
|
||||
notes: item.notes,
|
||||
addresses: addresses
|
||||
.filter(
|
||||
(address) => address.revisionId === row.id && address.interfaceId === item.id
|
||||
)
|
||||
.sort((a, b) => a.position - b.position)
|
||||
.map((address) => ({ id: address.id, address: address.address })),
|
||||
})),
|
||||
});
|
||||
|
||||
if (!values.isSuccess) {
|
||||
throw new Error("Valores de infraestructura persistidos inválidos.");
|
||||
}
|
||||
|
||||
return InfrastructureRevision.rehydrate(infrastructureIdentity(row.id), {
|
||||
values: values.data,
|
||||
actorId: infrastructureIdentity(row.actorId),
|
||||
recordedAt: row.recordedAt,
|
||||
});
|
||||
});
|
||||
|
||||
if (!history.length) {
|
||||
throw new Error("Infraestructura persistida sin historial.");
|
||||
}
|
||||
|
||||
return AssetInfrastructure.rehydrate(infrastructureIdentity(root.id), {
|
||||
companyId: infrastructureIdentity(root.companyId),
|
||||
assetId: infrastructureIdentity(root.assetId),
|
||||
history,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
import type { Sequelize, Transaction } from "sequelize";
|
||||
|
||||
/** Datos propios del activo: su borrado acompaña a la eliminación del inventario en la misma transacción. */
|
||||
export async function removeAssetInfrastructure(
|
||||
database: Sequelize,
|
||||
companyId: string,
|
||||
assetId: string,
|
||||
transaction: Transaction
|
||||
) {
|
||||
const replacements = { companyId, assetId };
|
||||
|
||||
for (const table of [
|
||||
"asset_interface_address_snapshots",
|
||||
"asset_interface_snapshots",
|
||||
"asset_infrastructure_roles",
|
||||
]) {
|
||||
await database.query(
|
||||
`DELETE s FROM ${table} s JOIN asset_infrastructure_revisions r ON r.id=s.revisionId JOIN asset_infrastructure i ON i.id=r.infrastructureId WHERE i.companyId=:companyId AND i.assetId=:assetId`,
|
||||
{ replacements, transaction }
|
||||
);
|
||||
}
|
||||
await database.query(
|
||||
"DELETE r FROM asset_infrastructure_revisions r JOIN asset_infrastructure i ON i.id=r.infrastructureId WHERE i.companyId=:companyId AND i.assetId=:assetId",
|
||||
{ replacements, transaction }
|
||||
);
|
||||
await database.query(
|
||||
"DELETE FROM asset_infrastructure WHERE companyId=:companyId AND assetId=:assetId",
|
||||
{ replacements, transaction }
|
||||
);
|
||||
}
|
||||
@ -16,6 +16,7 @@ import { sqlIdentifier } from "./asset-relational-sql";
|
||||
import { RelationshipModel } from "./asset-relationship.model";
|
||||
import { AssetRelationshipPersistenceMapper } from "./asset-relationship-persistence.mapper";
|
||||
import { loadWorkstations } from "./load-workstations";
|
||||
import { removeAssetInfrastructure } from "./remove-asset-infrastructure";
|
||||
import { readBrands } from "./sequelize-brand.repository";
|
||||
import { WorkstationAssetModel } from "./workstation.model";
|
||||
import { WorkstationPersistenceMapper } from "./workstation-persistence.mapper";
|
||||
@ -244,6 +245,7 @@ export class SequelizeAssetRepository implements IAssetRepository {
|
||||
{ type: QueryTypes.SELECT, transaction }
|
||||
);
|
||||
const ownedTables = new Set([
|
||||
"asset_infrastructure",
|
||||
"asset_characteristics",
|
||||
"asset_revision_characteristics",
|
||||
"asset_revisions",
|
||||
@ -270,6 +272,7 @@ export class SequelizeAssetRepository implements IAssetRepository {
|
||||
}
|
||||
|
||||
private async removeInventory(assetId: string, companyId: string, transaction: Transaction) {
|
||||
await removeAssetInfrastructure(this.database, companyId, assetId, transaction);
|
||||
for (const table of [
|
||||
"asset_revision_characteristics",
|
||||
"asset_characteristics",
|
||||
|
||||
@ -0,0 +1,141 @@
|
||||
import type { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Result } from "@repo/rdx-utils";
|
||||
import type { Sequelize, Transaction } from "sequelize";
|
||||
import { QueryTypes } from "sequelize";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { IInfrastructureRepository } from "../../application/infrastructure.repository";
|
||||
import { AssetCatalogError } from "../../domain/asset-definition";
|
||||
|
||||
import { writeAssetRow } from "./asset-relational-sql";
|
||||
import {
|
||||
AddressRowSchema,
|
||||
InfrastructurePersistenceMapper,
|
||||
InfrastructureRevisionRowSchema,
|
||||
InfrastructureRoleRowSchema,
|
||||
InfrastructureRootRowSchema,
|
||||
InterfaceRowSchema,
|
||||
} from "./infrastructure-persistence.mapper";
|
||||
import { writeInfrastructureRevision } from "./write-infrastructure-revision";
|
||||
|
||||
export class SequelizeInfrastructureRepository implements IInfrastructureRepository {
|
||||
constructor(private readonly database: Sequelize) {}
|
||||
|
||||
private async load(company: UniqueID, transaction: Transaction, lock = false) {
|
||||
const companies = await this.database.query<{ id: string }>(
|
||||
`SELECT id FROM companies WHERE id=:id${lock ? " FOR UPDATE" : ""}`,
|
||||
{ replacements: { id: company.toPrimitive() }, type: QueryTypes.SELECT, transaction }
|
||||
);
|
||||
|
||||
if (!companies.length) {
|
||||
return Result.fail(new AssetCatalogError("COMPANY_NOT_FOUND"));
|
||||
}
|
||||
|
||||
const select = async (table: string) =>
|
||||
this.database.query(
|
||||
`SELECT t.* FROM ${table} t JOIN asset_infrastructure_revisions r ON r.id=t.revisionId JOIN asset_infrastructure i ON i.id=r.infrastructureId WHERE i.companyId=:companyId`,
|
||||
{ replacements: { companyId: company.toPrimitive() }, type: QueryTypes.SELECT, transaction }
|
||||
);
|
||||
|
||||
const roots = z.array(InfrastructureRootRowSchema).parse(
|
||||
await this.database.query("SELECT * FROM asset_infrastructure WHERE companyId=:companyId", {
|
||||
replacements: { companyId: company.toPrimitive() },
|
||||
type: QueryTypes.SELECT,
|
||||
transaction,
|
||||
})
|
||||
);
|
||||
const revisions = z.array(InfrastructureRevisionRowSchema).parse(
|
||||
await this.database.query(
|
||||
"SELECT r.* FROM asset_infrastructure_revisions r JOIN asset_infrastructure i ON i.id=r.infrastructureId WHERE i.companyId=:companyId",
|
||||
{
|
||||
replacements: { companyId: company.toPrimitive() },
|
||||
type: QueryTypes.SELECT,
|
||||
transaction,
|
||||
}
|
||||
)
|
||||
);
|
||||
const roles = z
|
||||
.array(InfrastructureRoleRowSchema)
|
||||
.parse(await select("asset_infrastructure_roles"));
|
||||
const interfaces = z.array(InterfaceRowSchema).parse(await select("asset_interface_snapshots"));
|
||||
const addresses = z
|
||||
.array(AddressRowSchema)
|
||||
.parse(await select("asset_interface_address_snapshots"));
|
||||
const actors = z.array(z.object({ id: z.uuid(), name: z.string() })).parse(
|
||||
await this.database.query("SELECT id,name FROM users", {
|
||||
type: QueryTypes.SELECT,
|
||||
transaction,
|
||||
})
|
||||
);
|
||||
const mapper = new InfrastructurePersistenceMapper();
|
||||
|
||||
return Result.ok({
|
||||
items: roots.map((root) => mapper.toDomain(root, revisions, roles, interfaces, addresses)),
|
||||
actorNames: new Map(actors.map((actor) => [actor.id, actor.name])),
|
||||
});
|
||||
}
|
||||
|
||||
get(company: UniqueID) {
|
||||
return this.database.transaction((transaction) => this.load(company, transaction));
|
||||
}
|
||||
|
||||
save(
|
||||
company: UniqueID,
|
||||
asset: UniqueID,
|
||||
change: Parameters<IInfrastructureRepository["save"]>[2]
|
||||
) {
|
||||
return this.database.transaction(async (transaction) => {
|
||||
const loaded = await this.load(company, transaction, true);
|
||||
|
||||
if (!loaded.isSuccess) {
|
||||
return Result.fail(loaded.error);
|
||||
}
|
||||
const assets = await this.database.query<{ id: string }>(
|
||||
"SELECT id FROM assets WHERE companyId=:companyId AND id=:assetId FOR UPDATE",
|
||||
{
|
||||
replacements: { companyId: company.toPrimitive(), assetId: asset.toPrimitive() },
|
||||
type: QueryTypes.SELECT,
|
||||
transaction,
|
||||
}
|
||||
);
|
||||
|
||||
if (!assets.length) {
|
||||
return Result.fail(new AssetCatalogError("ASSET_NOT_FOUND"));
|
||||
}
|
||||
const previous = loaded.data.items.find((item) => item.assetId.equals(asset));
|
||||
const changed = change(previous);
|
||||
|
||||
if (!changed.isSuccess) {
|
||||
return Result.fail(changed.error);
|
||||
}
|
||||
const item = changed.data;
|
||||
|
||||
if (!previous) {
|
||||
await writeAssetRow(
|
||||
this.database,
|
||||
"asset_infrastructure",
|
||||
{
|
||||
id: item.id.toPrimitive(),
|
||||
companyId: company.toPrimitive(),
|
||||
assetId: asset.toPrimitive(),
|
||||
},
|
||||
transaction
|
||||
);
|
||||
}
|
||||
for (const [position, revision] of item.history.entries()) {
|
||||
if (previous?.history.some((old) => old.id.equals(revision.id))) {
|
||||
continue;
|
||||
}
|
||||
await writeInfrastructureRevision(
|
||||
this.database,
|
||||
item.id.toPrimitive(),
|
||||
revision,
|
||||
position,
|
||||
transaction
|
||||
);
|
||||
}
|
||||
|
||||
return Result.ok({ item, actorNames: loaded.data.actorNames });
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
import type { Sequelize, Transaction } from "sequelize";
|
||||
|
||||
import type { InfrastructureRevision } from "../../domain/infrastructure-revision.entity";
|
||||
|
||||
import { writeAssetRow } from "./asset-relational-sql";
|
||||
|
||||
/** Solo inserta las instantáneas nuevas; el repositorio conserva las anteriores. */
|
||||
export async function writeInfrastructureRevision(
|
||||
database: Sequelize,
|
||||
infrastructureId: string,
|
||||
revision: InfrastructureRevision,
|
||||
position: number,
|
||||
transaction: Transaction
|
||||
) {
|
||||
const revisionId = revision.id.toPrimitive();
|
||||
|
||||
await writeAssetRow(
|
||||
database,
|
||||
"asset_infrastructure_revisions",
|
||||
{
|
||||
id: revisionId,
|
||||
infrastructureId: infrastructureId,
|
||||
actorId: revision.actorId.toPrimitive(),
|
||||
recordedAt: revision.recordedAt,
|
||||
position,
|
||||
},
|
||||
transaction
|
||||
);
|
||||
const values = revision.values.toPrimitive();
|
||||
|
||||
for (const role of values.roles) {
|
||||
await writeAssetRow(database, "asset_infrastructure_roles", { revisionId, role }, transaction);
|
||||
}
|
||||
for (const [index, entry] of values.interfaces.entries()) {
|
||||
await writeAssetRow(
|
||||
database,
|
||||
"asset_interface_snapshots",
|
||||
{
|
||||
revisionId,
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
kind: entry.kind,
|
||||
mac: entry.mac,
|
||||
notes: entry.notes,
|
||||
position: index,
|
||||
},
|
||||
transaction
|
||||
);
|
||||
for (const [addressPosition, address] of entry.addresses.entries()) {
|
||||
await writeAssetRow(
|
||||
database,
|
||||
"asset_interface_address_snapshots",
|
||||
{
|
||||
revisionId,
|
||||
id: address.id,
|
||||
interfaceId: entry.id,
|
||||
address: address.address,
|
||||
position: addressPosition,
|
||||
},
|
||||
transaction
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,7 @@ import { AssetUseCases } from "./application/asset.use-cases";
|
||||
import { AssetCatalogUseCases } from "./application/asset-catalog.use-cases";
|
||||
import { AssetRelationshipUseCases } from "./application/asset-relationship.use-cases";
|
||||
import { BrandUseCases } from "./application/brand.use-cases";
|
||||
import { InfrastructureUseCases } from "./application/infrastructure.use-cases";
|
||||
import { SupportUseCases } from "./application/support.use-cases";
|
||||
import { WorkstationUseCases } from "./application/workstation.use-cases";
|
||||
import { seedAssetCatalog } from "./domain/asset-catalog-seed";
|
||||
@ -15,6 +16,7 @@ import { registerAssetRoutes } from "./infrastructure/http/asset.routes";
|
||||
import { registerAssetCatalogRoutes } from "./infrastructure/http/asset-catalog.routes";
|
||||
import { registerRelationshipRoutes } from "./infrastructure/http/asset-relationship.routes";
|
||||
import { registerBrandRoutes } from "./infrastructure/http/brand.routes";
|
||||
import { registerInfrastructureRoutes } from "./infrastructure/http/infrastructure.routes";
|
||||
import { registerSupportRoutes } from "./infrastructure/http/support.routes";
|
||||
import { registerWorkstationRoutes } from "./infrastructure/http/workstation.routes";
|
||||
import { migrateInventory } from "./infrastructure/persistence/asset.model";
|
||||
@ -31,9 +33,11 @@ import {
|
||||
import { initializeAssetInventorySchema } from "./infrastructure/persistence/asset-relational-schema";
|
||||
import { migrateRelationships } from "./infrastructure/persistence/asset-relationship.model";
|
||||
import { migrateBrands } from "./infrastructure/persistence/brand-migration";
|
||||
import { migrateInfrastructure } from "./infrastructure/persistence/infrastructure-migration";
|
||||
import { SequelizeAssetRepository } from "./infrastructure/persistence/sequelize-asset.repository";
|
||||
import { SequelizeAssetCatalogRepository } from "./infrastructure/persistence/sequelize-asset-catalog.repository";
|
||||
import { SequelizeBrandRepository } from "./infrastructure/persistence/sequelize-brand.repository";
|
||||
import { SequelizeInfrastructureRepository } from "./infrastructure/persistence/sequelize-infrastructure.repository";
|
||||
import { SequelizeSupportRepository } from "./infrastructure/persistence/sequelize-support.repository";
|
||||
import { migrateSupport } from "./infrastructure/persistence/support-migration";
|
||||
import { migrateWorkstations } from "./infrastructure/persistence/workstation.model";
|
||||
@ -65,6 +69,10 @@ export async function createAssetModule(database: Sequelize, authenticate: Reque
|
||||
await initializeAssetInventorySchema(database);
|
||||
await enforceAssetRelationalConstraints(database);
|
||||
await migrateSupport(database);
|
||||
await migrateInfrastructure(database);
|
||||
const infrastructure = new InfrastructureUseCases(
|
||||
new SequelizeInfrastructureRepository(database)
|
||||
);
|
||||
const support = new SupportUseCases(new SequelizeSupportRepository(database));
|
||||
const assetRepository = new SequelizeAssetRepository(database);
|
||||
const inventory = new AssetUseCases(assetRepository);
|
||||
@ -101,6 +109,7 @@ export async function createAssetModule(database: Sequelize, authenticate: Reque
|
||||
},
|
||||
},
|
||||
register: (app: Parameters<typeof registerAssetCatalogRoutes>[0]) => {
|
||||
registerInfrastructureRoutes(app, infrastructure, authenticate);
|
||||
registerBrandRoutes(app, brands, authenticate);
|
||||
registerAssetCatalogRoutes(app, useCases, authenticate);
|
||||
registerAssetRoutes(app, inventory, authenticate);
|
||||
|
||||
@ -20,6 +20,7 @@ export const CatalogItemSchema = z.strictObject({
|
||||
id: z.uuid(),
|
||||
companyId: z.uuid().nullable(),
|
||||
builtin: z.boolean(),
|
||||
builtinKey: z.string().nullable().optional(),
|
||||
referenceCatalog: z.literal("brands").optional(),
|
||||
active: z.boolean(),
|
||||
effectiveActive: z.boolean(),
|
||||
@ -39,11 +40,8 @@ export type CatalogResponseDTO = z.infer<typeof CatalogResponseSchema>;
|
||||
export type SaveDefinitionResponseDTO = z.infer<typeof SaveDefinitionResponseSchema>;
|
||||
|
||||
export * from "./brands";
|
||||
|
||||
export * from "./infrastructure";
|
||||
export * from "./inventory";
|
||||
|
||||
export * from "./relationships";
|
||||
|
||||
export * from "./support";
|
||||
|
||||
export * from "./workstations";
|
||||
|
||||
47
modules/assets/src/common/infrastructure.ts
Normal file
47
modules/assets/src/common/infrastructure.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
InfrastructureInputSchema,
|
||||
InfrastructureRoleSchema,
|
||||
InfrastructureValuesSchema,
|
||||
} from "../api/domain/infrastructure-values";
|
||||
|
||||
export type { InfrastructureData, InfrastructureInput } from "../api/domain/infrastructure-values";
|
||||
|
||||
export { InfrastructureInputSchema, InfrastructureRoleSchema };
|
||||
|
||||
export const InfrastructureParamsSchema = z.strictObject({ companyId: z.uuid() });
|
||||
|
||||
export const InfrastructureItemParamsSchema = InfrastructureParamsSchema.extend({
|
||||
assetId: z.uuid(),
|
||||
});
|
||||
|
||||
export const SaveInfrastructureRequestSchema = z.strictObject({
|
||||
values: InfrastructureInputSchema,
|
||||
});
|
||||
|
||||
export const InfrastructureSchema = z.strictObject({
|
||||
id: z.uuid(),
|
||||
companyId: z.uuid(),
|
||||
assetId: z.uuid(),
|
||||
values: InfrastructureValuesSchema,
|
||||
history: z
|
||||
.array(
|
||||
z.strictObject({
|
||||
id: z.uuid(),
|
||||
values: InfrastructureValuesSchema,
|
||||
actorId: z.uuid(),
|
||||
actorName: z.string(),
|
||||
recordedAt: z.iso.datetime(),
|
||||
})
|
||||
)
|
||||
.min(1),
|
||||
});
|
||||
|
||||
export const InfrastructureResponseSchema = z.strictObject({
|
||||
items: z.array(InfrastructureSchema),
|
||||
});
|
||||
|
||||
export const SavedInfrastructureResponseSchema = z.strictObject({ item: InfrastructureSchema });
|
||||
|
||||
export type InfrastructureDTO = z.infer<typeof InfrastructureSchema>;
|
||||
@ -475,5 +475,45 @@
|
||||
"templateDescription": "Select the template that defines the asset characteristics.",
|
||||
"fieldsDescription": "Complete the template details. Fields marked with * are required.",
|
||||
"nameDescription": "Review the suggested name, technical status, site and registration date. Add notes if you need to provide more context."
|
||||
},
|
||||
"infrastructure": {
|
||||
"title": "Infrastructure",
|
||||
"description": "Browse shared devices, interfaces, addresses and connections between assets.",
|
||||
"edit": "Edit infrastructure",
|
||||
"add": "Add to infrastructure",
|
||||
"rolesTitle": "Device roles",
|
||||
"roles": {
|
||||
"network": "Network",
|
||||
"server": "Server",
|
||||
"storage": "Storage",
|
||||
"shared_peripheral": "Shared peripheral"
|
||||
},
|
||||
"interfaces": "Interfaces and addresses",
|
||||
"connections": "Device connections",
|
||||
"usage": "Where it is used",
|
||||
"kind": "Interface type",
|
||||
"kinds": {
|
||||
"ethernet": "Ethernet",
|
||||
"wifi": "Wi-Fi",
|
||||
"other": "Other"
|
||||
},
|
||||
"mac": "MAC address",
|
||||
"addresses": "IP addresses",
|
||||
"addAddress": "Add IP",
|
||||
"addInterface": "Add interface",
|
||||
"removeInterface": "Remove interface",
|
||||
"interfacePosition": "Interface {{count}}",
|
||||
"addressPosition": "IP address {{count}}",
|
||||
"removeAddressPosition": "Remove IP address {{count}}",
|
||||
"invalid": "Check the fields. Interface names must be unique and addresses cannot be repeated within an interface.",
|
||||
"invalidIp": "Enter a valid IPv4 or IPv6 address without a mask.",
|
||||
"invalidMac": "Enter a valid MAC such as AA:BB:CC:DD:EE:FF, or leave it blank.",
|
||||
"emptyInterfaces": "No interfaces have been documented.",
|
||||
"all": "All roles",
|
||||
"helpTitle": "Interfaces and shared use",
|
||||
"help": "Document the device roles and interfaces whose IP addresses you need. An interface may have no IP or several addresses.",
|
||||
"examplesLabel": "Example",
|
||||
"examples": "Printer with Ethernet 192.168.5.201 and Wi-Fi 192.168.5.202.",
|
||||
"guidance": "Record device connections in Relationships. Port details remain in UniFi. This does not change characteristics or workstation assignments."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -475,5 +475,45 @@
|
||||
"templateDescription": "Selecciona la plantilla que define las características del activo.",
|
||||
"fieldsDescription": "Completa los datos de la plantilla. Los campos marcados con * son obligatorios.",
|
||||
"nameDescription": "Revisa el nombre propuesto, el estado técnico, la sede y la fecha de alta. Añade notas si necesitas aportar más contexto."
|
||||
},
|
||||
"infrastructure": {
|
||||
"title": "Infraestructura",
|
||||
"description": "Consulta equipos compartidos, interfaces, direcciones y conexiones entre activos.",
|
||||
"edit": "Editar infraestructura",
|
||||
"add": "Añadir a infraestructura",
|
||||
"rolesTitle": "Funciones del equipo",
|
||||
"roles": {
|
||||
"network": "Red",
|
||||
"server": "Servidor",
|
||||
"storage": "Almacenamiento",
|
||||
"shared_peripheral": "Periférico compartido"
|
||||
},
|
||||
"interfaces": "Interfaces y direcciones",
|
||||
"connections": "Conexiones entre equipos",
|
||||
"usage": "Dónde se utiliza",
|
||||
"kind": "Tipo de interfaz",
|
||||
"kinds": {
|
||||
"ethernet": "Ethernet",
|
||||
"wifi": "Wi-Fi",
|
||||
"other": "Otra"
|
||||
},
|
||||
"mac": "Dirección MAC",
|
||||
"addresses": "Direcciones IP",
|
||||
"addAddress": "Añadir IP",
|
||||
"addInterface": "Añadir interfaz",
|
||||
"removeInterface": "Quitar interfaz",
|
||||
"interfacePosition": "Interfaz {{count}}",
|
||||
"addressPosition": "Dirección IP {{count}}",
|
||||
"removeAddressPosition": "Quitar dirección IP {{count}}",
|
||||
"invalid": "Revisa los campos. Los nombres de interfaz deben ser únicos y no puede repetirse una IP dentro de la misma interfaz.",
|
||||
"invalidIp": "Introduce una dirección IPv4 o IPv6 válida, sin máscara.",
|
||||
"invalidMac": "Introduce una MAC válida, por ejemplo AA:BB:CC:DD:EE:FF, o deja el campo vacío.",
|
||||
"emptyInterfaces": "No hay interfaces documentadas.",
|
||||
"all": "Todas las funciones",
|
||||
"helpTitle": "Interfaces y uso compartido",
|
||||
"help": "Documenta las funciones del equipo y las interfaces cuya IP necesitas conocer. Una interfaz puede no tener IP o tener varias.",
|
||||
"examplesLabel": "Ejemplo",
|
||||
"examples": "Impresora con Ethernet 192.168.5.201 y Wi-Fi 192.168.5.202.",
|
||||
"guidance": "Las conexiones entre equipos se registran en Relaciones. El detalle de puertos permanece en UniFi. No se modifican características ni asignaciones del puesto."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import { FieldSections } from "@repo/rdx-ui/components/field-sections";
|
||||
import { FormSectionTitle } from "@repo/rdx-ui/components/form-section-title";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AssetInfrastructureSections } from "../../../infrastructure/shared/ui/asset-infrastructure-sections";
|
||||
import { formatAssetRegistrationDate } from "../../shared/helpers/format-asset-registration-date";
|
||||
import { useAssetDetailController } from "../controllers";
|
||||
import type { AssetDetailProps } from "../types";
|
||||
@ -122,6 +123,11 @@ export function AssetDetail(props: AssetDetailProps) {
|
||||
<p className="whitespace-pre-wrap break-words text-sm">{asset.values.notes}</p>
|
||||
</DetailSection>
|
||||
)}
|
||||
<AssetInfrastructureSections
|
||||
asset={asset}
|
||||
compact={compact}
|
||||
renderUsage={lookups.renderUsage}
|
||||
/>
|
||||
{children}
|
||||
{!compact && (
|
||||
<>
|
||||
|
||||
@ -75,6 +75,8 @@ export function AssetSelectionPanel({
|
||||
<Accordion
|
||||
defaultValue={[
|
||||
"assignment",
|
||||
"infrastructure",
|
||||
"usage",
|
||||
"characteristics",
|
||||
"ungrouped",
|
||||
"notes",
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import type { CatalogItemDTO } from "../../../../common";
|
||||
import type { AssetDTO } from "../../../../common/inventory";
|
||||
|
||||
export type InventoryLookups = {
|
||||
renderUsage?: (assetId: string) => ReactNode;
|
||||
sites: readonly {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@ -4,6 +4,11 @@ export { AssetSummaryCard } from "./assets/shared/ui/asset-summary-card";
|
||||
export { BrandCatalogPage } from "./brands";
|
||||
export { AssetCatalogPage } from "./catalog";
|
||||
export { catalogApi, catalogKeys } from "./catalog/shared/api";
|
||||
export { InfrastructurePage } from "./infrastructure/list/ui/infrastructure-page";
|
||||
export {
|
||||
infrastructureApi,
|
||||
infrastructureKeys,
|
||||
} from "./infrastructure/shared/api/infrastructure-api";
|
||||
export { assetsEnglish, assetsSpanish } from "./shared";
|
||||
export { inventoryApi } from "./shared/api";
|
||||
export { inventoryKeys } from "./shared/queries";
|
||||
|
||||
@ -0,0 +1,252 @@
|
||||
import { BrowserLink, useListQuery } from "@metis/core/client";
|
||||
import {
|
||||
EntityListHeader,
|
||||
EntityRowActions,
|
||||
EntityStatus,
|
||||
EntityTable,
|
||||
} from "@repo/rdx-ui/components/entity-list";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { NetworkIcon, PencilIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { useId, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { AssetSelect } from "../../../assets/select/ui/asset-select";
|
||||
import { catalogApi, catalogKeys } from "../../../catalog/shared/api";
|
||||
import { InventoryFeedback } from "../../../shared";
|
||||
import { inventoryApi } from "../../../shared/api";
|
||||
import { inventoryKeys } from "../../../shared/queries";
|
||||
import { infrastructureApi, infrastructureKeys } from "../../shared/api/infrastructure-api";
|
||||
import { infrastructureRoles } from "../../shared/helpers/infrastructure-roles";
|
||||
import { InfrastructureEditorDialog } from "../../shared/ui/infrastructure-editor-dialog";
|
||||
|
||||
export function InfrastructurePage({ companyId }: { companyId: string }) {
|
||||
const { t, i18n } = useTranslation("assets");
|
||||
const prefix = useId();
|
||||
const [role, setRole] = useState("");
|
||||
const [editingId, setEditingId] = useState("");
|
||||
const inventory = useListQuery({
|
||||
queryKey: inventoryKeys.company(companyId),
|
||||
queryFn: () => inventoryApi.get(companyId),
|
||||
});
|
||||
const network = useListQuery({
|
||||
queryKey: infrastructureKeys.company(companyId),
|
||||
queryFn: () => infrastructureApi.get(companyId),
|
||||
});
|
||||
const catalog = useListQuery({
|
||||
queryKey: catalogKeys.scope(companyId),
|
||||
queryFn: () => catalogApi.get(companyId),
|
||||
});
|
||||
const assets = inventory.data?.assets ?? [];
|
||||
const editing = assets.find((asset) => asset.id === editingId);
|
||||
const items = assets.flatMap((asset) => {
|
||||
const config = network.data?.items.find((item) => item.assetId === asset.id);
|
||||
|
||||
const roles = infrastructureRoles(asset.values.templateId, catalog.data?.items ?? [], config);
|
||||
|
||||
return (roles.length || config?.values.interfaces.length) &&
|
||||
(!role || roles.some((value) => value === role))
|
||||
? [{ ...asset, config: { roles, interfaces: config?.values.interfaces ?? [] } }]
|
||||
: [];
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<EntityListHeader
|
||||
actions={
|
||||
<AssetSelect
|
||||
companyId={companyId}
|
||||
disabled={!(inventory.isSuccess && network.isSuccess && catalog.isSuccess)}
|
||||
id={`${prefix}-choose`}
|
||||
label={t("infrastructure.add")}
|
||||
onValueChange={setEditingId}
|
||||
options={assets.map((asset) => ({ value: asset.id, label: asset.values.name }))}
|
||||
placeholder={t("infrastructure.add")}
|
||||
presentation="action"
|
||||
required
|
||||
value=""
|
||||
/>
|
||||
}
|
||||
description={t("infrastructure.description")}
|
||||
icon={<NetworkIcon />}
|
||||
title={t("infrastructure.title")}
|
||||
/>
|
||||
<InventoryFeedback error={inventory.error ?? network.error ?? catalog.error} />
|
||||
{(inventory.isError || network.isError || catalog.isError) && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
Promise.all([inventory.refetch(), network.refetch(), catalog.refetch()]).catch(
|
||||
() => undefined
|
||||
);
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
)}
|
||||
<EntityTable
|
||||
columns={[
|
||||
{
|
||||
id: "name",
|
||||
label: t("name"),
|
||||
hideable: false,
|
||||
value: (item) => item.values.name,
|
||||
render: (item) => (
|
||||
<BrowserLink href={`/companies/${companyId}/assets/${item.id}`}>
|
||||
{item.values.name}
|
||||
</BrowserLink>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "roles",
|
||||
label: t("infrastructure.rolesTitle"),
|
||||
value: (item) =>
|
||||
item.config.roles.map((value) => t(`infrastructure.roles.${value}`)).join(" "),
|
||||
render: (item) =>
|
||||
item.config.roles.map((value) => t(`infrastructure.roles.${value}`)).join(" · "),
|
||||
},
|
||||
{
|
||||
id: "interfaces",
|
||||
label: t("infrastructure.interfaces"),
|
||||
value: (item) =>
|
||||
item.config.interfaces
|
||||
.map(
|
||||
(entry) =>
|
||||
`${entry.name} ${entry.addresses.map((address) => address.address).join(" ")}`
|
||||
)
|
||||
.join(" "),
|
||||
render: (item) =>
|
||||
item.config.interfaces.length ? (
|
||||
<ul>
|
||||
{item.config.interfaces.map((entry) => (
|
||||
<li key={entry.id}>
|
||||
{entry.name}:{" "}
|
||||
{entry.addresses.map((address) => address.address).join(" · ") || "—"}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
id: "connections",
|
||||
label: t("infrastructure.connections"),
|
||||
render: (item) => {
|
||||
const links =
|
||||
inventory.data?.relationships.filter(
|
||||
(link) =>
|
||||
!link.endedAt && [link.values.sourceId, link.values.targetId].includes(item.id)
|
||||
) ?? [];
|
||||
|
||||
return links.length ? (
|
||||
<ul>
|
||||
{links.map((link) => {
|
||||
const target =
|
||||
link.values.sourceId === item.id
|
||||
? link.values.targetId
|
||||
: link.values.sourceId;
|
||||
|
||||
return (
|
||||
<li key={link.id}>
|
||||
<BrowserLink href={`/companies/${companyId}/assets/${target}`}>
|
||||
{assets.find((asset) => asset.id === target)?.values.name ?? "—"}
|
||||
</BrowserLink>{" "}
|
||||
·{" "}
|
||||
{link.values.sourceId === item.id
|
||||
? link.values.name
|
||||
: link.values.inverseName}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
label: t("technicalStatus"),
|
||||
value: (item) => t(item.values.status),
|
||||
render: (item) => (
|
||||
<EntityStatus active={item.values.status === "operational"}>
|
||||
{t(item.values.status)}
|
||||
</EntityStatus>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "workstation",
|
||||
label: t("workstationInAsset"),
|
||||
render: (item) => {
|
||||
const stations =
|
||||
inventory.data?.workstations.filter(
|
||||
(station) => station.values.active && station.values.assetIds.includes(item.id)
|
||||
) ?? [];
|
||||
|
||||
return stations.length ? (
|
||||
<ul>
|
||||
{stations.map((station) => (
|
||||
<li key={station.id}>
|
||||
<BrowserLink href={`/companies/${companyId}/workstations/${station.id}`}>
|
||||
{station.values.name}
|
||||
</BrowserLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
label: t("actions"),
|
||||
render: (item) => (
|
||||
<EntityRowActions
|
||||
label={t("actions")}
|
||||
primaryAction={{
|
||||
label: t("infrastructure.edit"),
|
||||
icon: <PencilIcon aria-hidden="true" />,
|
||||
onClick: () => setEditingId(item.id),
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
filterKey={role}
|
||||
filters={
|
||||
<SelectWithAction
|
||||
id={`${prefix}-role`}
|
||||
label={t("infrastructure.rolesTitle")}
|
||||
onValueChange={setRole}
|
||||
options={[
|
||||
{ value: "", label: t("infrastructure.all") },
|
||||
...["network", "server", "storage", "shared_peripheral"].map((value) => ({
|
||||
value,
|
||||
label: t(`infrastructure.roles.${value}`),
|
||||
})),
|
||||
]}
|
||||
placeholder={t("infrastructure.all")}
|
||||
value={role}
|
||||
/>
|
||||
}
|
||||
filtersApplied={Boolean(role)}
|
||||
items={items}
|
||||
loading={inventory.isPending || network.isPending || catalog.isPending}
|
||||
locale={i18n.language}
|
||||
onClearFilters={() => setRole("")}
|
||||
title={t("infrastructure.title")}
|
||||
unavailable={
|
||||
(!inventory.data && inventory.isError) ||
|
||||
(!network.data && network.isError) ||
|
||||
(!catalog.data && catalog.isError)
|
||||
}
|
||||
/>
|
||||
{editing && (
|
||||
<InfrastructureEditorDialog
|
||||
assetId={editing.id}
|
||||
assetName={editing.values.name}
|
||||
companyId={companyId}
|
||||
current={network.data?.items.find((item) => item.assetId === editing.id)}
|
||||
initialRoles={infrastructureRoles(editing.values.templateId, catalog.data?.items ?? [])}
|
||||
onClose={() => setEditingId("")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
import { csrfFetch } from "@metis/core/client";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import type { InfrastructureInput } from "../../../../common/infrastructure";
|
||||
import {
|
||||
InfrastructureResponseSchema,
|
||||
SavedInfrastructureResponseSchema,
|
||||
} from "../../../../common/infrastructure";
|
||||
import { InventoryApiError } from "../../../shared/api/inventory-api-error";
|
||||
|
||||
export const infrastructureKeys = { company: (id: string) => ["asset-infrastructure", id] };
|
||||
|
||||
async function request(companyId: string, assetId?: string, values?: InfrastructureInput) {
|
||||
const response = await csrfFetch(
|
||||
`/api/asset-infrastructure/${companyId}${assetId ? `/${assetId}` : ""}`,
|
||||
{
|
||||
method: values ? "POST" : "GET",
|
||||
credentials: "same-origin",
|
||||
...(values
|
||||
? { headers: { "content-type": "application/json" }, body: JSON.stringify({ values }) }
|
||||
: {}),
|
||||
}
|
||||
);
|
||||
const body: unknown = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
const error = z.object({ code: z.string() }).safeParse(body);
|
||||
|
||||
throw new InventoryApiError(error.success ? error.data.code : "INTERNAL_ERROR");
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
export const infrastructureApi = {
|
||||
async get(companyId: string) {
|
||||
return InfrastructureResponseSchema.parse(await request(companyId));
|
||||
},
|
||||
async save(companyId: string, assetId: string, values: InfrastructureInput) {
|
||||
return SavedInfrastructureResponseSchema.parse(await request(companyId, assetId, values));
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,28 @@
|
||||
import type { CatalogItemDTO } from "../../../../common";
|
||||
import type { InfrastructureDTO, InfrastructureInput } from "../../../../common/infrastructure";
|
||||
|
||||
/** Las plantillas de serie usan su clave estable; las clasificaciones guardadas prevalecen. */
|
||||
export function infrastructureRoles(
|
||||
templateId: string,
|
||||
catalog: readonly CatalogItemDTO[],
|
||||
current?: InfrastructureDTO
|
||||
): InfrastructureInput["roles"] {
|
||||
if (current) {
|
||||
return current.values.roles;
|
||||
}
|
||||
const key = catalog.find((item) => item.id === templateId)?.builtinKey;
|
||||
|
||||
switch (key) {
|
||||
case "template:router":
|
||||
case "template:firewall":
|
||||
case "template:switch":
|
||||
case "template:access_point":
|
||||
return ["network"];
|
||||
case "template:server":
|
||||
return ["server"];
|
||||
case "template:nas":
|
||||
return ["storage"];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,167 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { Maybe } from "@repo/rdx-utils";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createInstance } from "i18next";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
|
||||
import { buildCatalogResponse } from "../../../api/application/asset-catalog-response.builder";
|
||||
import { AssetCatalog } from "../../../api/domain/asset-catalog.aggregate";
|
||||
import { seedAssetCatalog } from "../../../api/domain/asset-catalog-seed";
|
||||
import { InfrastructureValues } from "../../../api/domain/value-objects/infrastructure-values.vo";
|
||||
import { InfrastructureSchema } from "../../../common/infrastructure";
|
||||
import { AssetSchema } from "../../../common/inventory";
|
||||
import { catalogKeys } from "../../catalog/shared/api";
|
||||
import { assetsEnglish, assetsSpanish } from "../../shared";
|
||||
import { inventoryKeys } from "../../shared/queries";
|
||||
import { InfrastructurePage } from "../list/ui/infrastructure-page";
|
||||
|
||||
import { infrastructureKeys } from "./api/infrastructure-api";
|
||||
import { infrastructureRoles } from "./helpers/infrastructure-roles";
|
||||
import { AssetInfrastructurePanel } from "./ui/asset-infrastructure-panel";
|
||||
|
||||
const companyId = UniqueID.generateNewID().toPrimitive();
|
||||
const catalog = buildCatalogResponse(seedAssetCatalog(AssetCatalog.create()), Maybe.none());
|
||||
const router = catalog.items.find((item) => item.builtinKey === "template:router");
|
||||
|
||||
assert.ok(router && router.values.kind === "template");
|
||||
const templateId = router.id;
|
||||
const values = {
|
||||
name: "Router principal",
|
||||
templateId,
|
||||
siteId: null,
|
||||
status: "operational",
|
||||
notes: "",
|
||||
characteristics: {},
|
||||
};
|
||||
const asset = AssetSchema.parse({
|
||||
id: UniqueID.generateNewID().toPrimitive(),
|
||||
companyId,
|
||||
values,
|
||||
assigned: false,
|
||||
displayStatus: "available",
|
||||
specification: {
|
||||
templateId,
|
||||
templateName: "Router",
|
||||
fields: [
|
||||
{
|
||||
fieldId: UniqueID.generateNewID().toPrimitive(),
|
||||
name: "Modelo",
|
||||
type: "text",
|
||||
unit: "",
|
||||
choices: [],
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
history: [
|
||||
{
|
||||
id: UniqueID.generateNewID().toPrimitive(),
|
||||
values,
|
||||
actorId: UniqueID.generateNewID().toPrimitive(),
|
||||
actorName: "Técnico",
|
||||
recordedAt: "2026-10-17T10:00:00.000Z",
|
||||
},
|
||||
],
|
||||
});
|
||||
const networkValues = InfrastructureValues.create({
|
||||
roles: ["network"],
|
||||
interfaces: [
|
||||
{
|
||||
name: "LAN",
|
||||
kind: "ethernet",
|
||||
mac: "",
|
||||
notes: "",
|
||||
addresses: [{ address: "192.168.5.201" }],
|
||||
},
|
||||
{ name: "Wi-Fi", kind: "wifi", mac: "", notes: "", addresses: [{ address: "192.168.5.202" }] },
|
||||
],
|
||||
});
|
||||
|
||||
assert.ok(networkValues.isSuccess);
|
||||
const snapshot = networkValues.data.toPrimitive();
|
||||
const config = InfrastructureSchema.parse({
|
||||
id: UniqueID.generateNewID().toPrimitive(),
|
||||
assetId: asset.id,
|
||||
companyId,
|
||||
values: snapshot,
|
||||
history: [
|
||||
{
|
||||
id: UniqueID.generateNewID().toPrimitive(),
|
||||
actorId: UniqueID.generateNewID().toPrimitive(),
|
||||
actorName: "Técnico",
|
||||
recordedAt: "2026-10-17T10:00:00.000Z",
|
||||
values: snapshot,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
test("infraestructura: clasificación de serie independiente del nombre/icono y selección manual prioritaria", () => {
|
||||
assert.ok(router.values.kind === "template");
|
||||
const renamed = {
|
||||
...router,
|
||||
values: { ...router.values, name: "Nombre propio", icon: "box" as const },
|
||||
};
|
||||
|
||||
assert.deepEqual(infrastructureRoles(templateId, [renamed]), ["network"]);
|
||||
assert.deepEqual(
|
||||
infrastructureRoles(templateId, [renamed], {
|
||||
...config,
|
||||
values: { ...config.values, roles: ["storage"] },
|
||||
}),
|
||||
["storage"]
|
||||
);
|
||||
assert.deepEqual(infrastructureRoles(templateId, [{ ...renamed, builtinKey: null }]), []);
|
||||
});
|
||||
|
||||
test("infraestructura: listado y ficha presentan interfaces/IP en español e inglés, sin puertos", async () => {
|
||||
for (const [language, title, edit] of [
|
||||
["es", "Infraestructura", "Editar infraestructura"],
|
||||
["en", "Infrastructure", "Edit infrastructure"],
|
||||
] as const) {
|
||||
const i18n = createInstance();
|
||||
|
||||
await i18n.init({
|
||||
lng: language,
|
||||
resources: { es: { assets: assetsSpanish }, en: { assets: assetsEnglish } },
|
||||
});
|
||||
const client = new QueryClient();
|
||||
|
||||
client.setQueryData(catalogKeys.scope(companyId), catalog);
|
||||
client.setQueryData(inventoryKeys.company(companyId), {
|
||||
assets: [asset],
|
||||
workstations: [],
|
||||
relationships: [],
|
||||
});
|
||||
client.setQueryData(infrastructureKeys.company(companyId), { items: [config] });
|
||||
try {
|
||||
const html = renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<InfrastructurePage companyId={companyId} />
|
||||
<AssetInfrastructurePanel
|
||||
assetId={asset.id}
|
||||
assetName={asset.values.name}
|
||||
companyId={companyId}
|
||||
templateId={templateId}
|
||||
/>
|
||||
</I18nextProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
assert.ok(html.includes(title));
|
||||
assert.ok(html.includes(edit));
|
||||
assert.ok(html.includes("192.168.5.201"));
|
||||
assert.ok(html.includes("192.168.5.202"));
|
||||
assert.ok(html.includes("Wi-Fi"));
|
||||
assert.ok(html.includes(`/companies/${companyId}/assets/${asset.id}`));
|
||||
assert.ok(html.includes('aria-haspopup="dialog"'));
|
||||
assert.equal(html.includes("infrastructure.roles.network"), false);
|
||||
} finally {
|
||||
client.clear();
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -0,0 +1,132 @@
|
||||
import { useListQuery } from "@metis/core/client";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { catalogApi, catalogKeys } from "../../../catalog/shared/api";
|
||||
import { InventoryFeedback } from "../../../shared";
|
||||
import { infrastructureApi, infrastructureKeys } from "../api/infrastructure-api";
|
||||
import { infrastructureRoles } from "../helpers/infrastructure-roles";
|
||||
|
||||
import { InfrastructureEditorDialog } from "./infrastructure-editor-dialog";
|
||||
|
||||
export function AssetInfrastructurePanel({
|
||||
companyId,
|
||||
assetId,
|
||||
assetName,
|
||||
templateId,
|
||||
}: {
|
||||
companyId: string;
|
||||
assetId: string;
|
||||
assetName: string;
|
||||
templateId: string;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("assets");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const query = useListQuery({
|
||||
queryKey: infrastructureKeys.company(companyId),
|
||||
queryFn: () => infrastructureApi.get(companyId),
|
||||
});
|
||||
const catalog = useListQuery({
|
||||
queryKey: catalogKeys.scope(companyId),
|
||||
queryFn: () => catalogApi.get(companyId),
|
||||
});
|
||||
const current = query.data?.items.find((item) => item.assetId === assetId);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{query.isPending && <p role="status">{t("loading")}</p>}
|
||||
<InventoryFeedback error={query.error ?? catalog.error} />
|
||||
{(query.isError || catalog.isError) && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
Promise.all([query.refetch(), catalog.refetch()]).catch(() => undefined);
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
)}
|
||||
{current?.values.roles.length ? (
|
||||
<p className="text-sm">
|
||||
{current.values.roles.map((role) => t(`infrastructure.roles.${role}`)).join(" · ")}
|
||||
</p>
|
||||
) : null}
|
||||
{query.isSuccess && !current?.values.interfaces.length && (
|
||||
<p className="text-sm text-muted-foreground">{t("infrastructure.emptyInterfaces")}</p>
|
||||
)}
|
||||
<ul className="space-y-3">
|
||||
{current?.values.interfaces.map((entry) => (
|
||||
<li className="rounded-md border p-3 text-sm" key={entry.id}>
|
||||
<p className="font-medium">
|
||||
{entry.name} · {t(`infrastructure.kinds.${entry.kind}`)}
|
||||
</p>
|
||||
<p>
|
||||
{entry.addresses.length
|
||||
? entry.addresses.map((address) => address.address).join(" · ")
|
||||
: "—"}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
{t("infrastructure.mac")}: {entry.mac || "—"}
|
||||
</p>
|
||||
{entry.notes && <p className="whitespace-pre-wrap">{entry.notes}</p>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button
|
||||
disabled={!(query.isSuccess && catalog.isSuccess)}
|
||||
onClick={() => setEditing(true)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("infrastructure.edit")}
|
||||
</Button>
|
||||
{current && (
|
||||
<details>
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
{t("history")} ({current.history.length})
|
||||
</summary>
|
||||
<ol className="mt-3 space-y-3">
|
||||
{[...current.history].reverse().map((revision) => (
|
||||
<li className="space-y-2 rounded-md border p-3 text-sm" key={revision.id}>
|
||||
<p>
|
||||
{new Intl.DateTimeFormat(i18n.language, {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(revision.recordedAt))}{" "}
|
||||
· {revision.actorName}
|
||||
</p>
|
||||
<p>
|
||||
{revision.values.roles
|
||||
.map((role) => t(`infrastructure.roles.${role}`))
|
||||
.join(" · ") || "—"}
|
||||
</p>
|
||||
<ul>
|
||||
{revision.values.interfaces.map((entry) => (
|
||||
<li key={entry.id}>
|
||||
{entry.name} · {t(`infrastructure.kinds.${entry.kind}`)} ·{" "}
|
||||
{entry.addresses.map((address) => address.address).join(" · ") || "—"} ·{" "}
|
||||
{entry.mac || "—"}
|
||||
{entry.notes && <p className="whitespace-pre-wrap">{entry.notes}</p>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
)}
|
||||
{editing && (
|
||||
<InfrastructureEditorDialog
|
||||
assetId={assetId}
|
||||
assetName={assetName}
|
||||
companyId={companyId}
|
||||
current={current}
|
||||
initialRoles={infrastructureRoles(templateId, catalog.data?.items ?? [])}
|
||||
onClose={() => setEditing(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
import { DetailSection } from "@repo/rdx-ui/components/detail-section";
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { AssetDTO } from "../../../../common/inventory";
|
||||
|
||||
import { AssetInfrastructurePanel } from "./asset-infrastructure-panel";
|
||||
|
||||
export function AssetInfrastructureSections({
|
||||
asset,
|
||||
compact,
|
||||
renderUsage,
|
||||
}: {
|
||||
asset: AssetDTO;
|
||||
compact: boolean;
|
||||
renderUsage: ((assetId: string) => ReactNode) | undefined;
|
||||
}) {
|
||||
const { t } = useTranslation("assets");
|
||||
|
||||
return (
|
||||
<>
|
||||
<DetailSection
|
||||
accordionValue={compact ? "infrastructure" : undefined}
|
||||
title={t("infrastructure.interfaces")}
|
||||
>
|
||||
<AssetInfrastructurePanel
|
||||
assetId={asset.id}
|
||||
assetName={asset.values.name}
|
||||
companyId={asset.companyId}
|
||||
templateId={asset.values.templateId}
|
||||
/>
|
||||
</DetailSection>
|
||||
{renderUsage && (
|
||||
<DetailSection
|
||||
accordionValue={compact ? "usage" : undefined}
|
||||
title={t("infrastructure.usage")}
|
||||
>
|
||||
{renderUsage(asset.id)}
|
||||
</DetailSection>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,145 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { FormActions, FormBody, FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormHelp } from "@repo/rdx-ui/components/form-help";
|
||||
import { FormSectionTitle } from "@repo/rdx-ui/components/form-section-title";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useId } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { InfrastructureDTO, InfrastructureInput } from "../../../../common/infrastructure";
|
||||
import {
|
||||
InfrastructureInputSchema,
|
||||
InfrastructureRoleSchema,
|
||||
} from "../../../../common/infrastructure";
|
||||
import { InventoryFeedback } from "../../../shared";
|
||||
import { infrastructureApi, infrastructureKeys } from "../api/infrastructure-api";
|
||||
|
||||
import { InterfaceFields } from "./interface-fields";
|
||||
|
||||
export function InfrastructureEditorDialog({
|
||||
companyId,
|
||||
assetId,
|
||||
assetName,
|
||||
current,
|
||||
initialRoles = [],
|
||||
onClose,
|
||||
}: {
|
||||
companyId: string;
|
||||
assetId: string;
|
||||
assetName: string;
|
||||
current: InfrastructureDTO | undefined;
|
||||
initialRoles?: InfrastructureInput["roles"];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation("assets");
|
||||
const prefix = useId();
|
||||
const client = useQueryClient();
|
||||
const form = useForm<InfrastructureInput>({
|
||||
resolver: zodResolver(InfrastructureInputSchema),
|
||||
defaultValues: current?.values ?? { roles: initialRoles, interfaces: [] },
|
||||
});
|
||||
const fields = useFieldArray({ control: form.control, name: "interfaces" });
|
||||
const roles = form.watch("roles");
|
||||
const mutation = useMutation({
|
||||
mutationFn: (values: InfrastructureInput) => infrastructureApi.save(companyId, assetId, values),
|
||||
onSuccess: async () => {
|
||||
await client.invalidateQueries({ queryKey: infrastructureKeys.company(companyId) });
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
layout="stable"
|
||||
onOpenChange={(open) => {
|
||||
if (!(open || mutation.isPending)) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
open
|
||||
size="large"
|
||||
title={`${t("infrastructure.edit")}: ${assetName}`}
|
||||
>
|
||||
<form
|
||||
className="flex min-h-0 flex-1 flex-col overflow-hidden"
|
||||
onSubmit={form.handleSubmit((values) => mutation.mutate(values))}
|
||||
>
|
||||
<FormBody>
|
||||
<fieldset className="space-y-5" disabled={mutation.isPending}>
|
||||
<div className="space-y-3">
|
||||
<FormSectionTitle>{t("infrastructure.rolesTitle")}</FormSectionTitle>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{InfrastructureRoleSchema.options.map((role) => (
|
||||
<label
|
||||
className="inline-flex items-center gap-2 text-sm"
|
||||
htmlFor={`${prefix}-${role}`}
|
||||
key={role}
|
||||
>
|
||||
<Checkbox
|
||||
checked={roles.includes(role)}
|
||||
disabled={mutation.isPending}
|
||||
id={`${prefix}-${role}`}
|
||||
onCheckedChange={(checked) =>
|
||||
form.setValue(
|
||||
"roles",
|
||||
checked ? [...roles, role] : roles.filter((item) => item !== role),
|
||||
{ shouldDirty: true }
|
||||
)
|
||||
}
|
||||
/>
|
||||
{t(`infrastructure.roles.${role}`)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<FormSectionTitle layout="inline">{t("infrastructure.interfaces")}</FormSectionTitle>
|
||||
<Button
|
||||
disabled={fields.fields.length >= 32}
|
||||
onClick={() =>
|
||||
fields.append({ name: "", kind: "ethernet", mac: "", notes: "", addresses: [] })
|
||||
}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{t("infrastructure.addInterface")}
|
||||
</Button>
|
||||
</div>
|
||||
{fields.fields.map((field, index) => (
|
||||
<InterfaceFields
|
||||
disabled={mutation.isPending}
|
||||
form={form}
|
||||
index={index}
|
||||
key={field.id}
|
||||
onRemove={() => fields.remove(index)}
|
||||
/>
|
||||
))}
|
||||
{form.formState.errors.interfaces && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{t("infrastructure.invalid")}
|
||||
</p>
|
||||
)}
|
||||
<InventoryFeedback error={mutation.error} />
|
||||
</fieldset>
|
||||
</FormBody>
|
||||
<FormHelp
|
||||
description={t("infrastructure.help")}
|
||||
examples={t("infrastructure.examples")}
|
||||
examplesLabel={t("infrastructure.examplesLabel")}
|
||||
guidance={t("infrastructure.guidance")}
|
||||
title={t("infrastructure.helpTitle")}
|
||||
/>
|
||||
<FormActions cancelDisabled={mutation.isPending}>
|
||||
<Button disabled={mutation.isPending} type="submit">
|
||||
{t(mutation.isPending ? "saving" : "save")}
|
||||
</Button>
|
||||
</FormActions>
|
||||
</form>
|
||||
</FormDialog>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,139 @@
|
||||
import { FormLabel } from "@repo/rdx-ui/components/form-label";
|
||||
import { FormSectionTitle } from "@repo/rdx-ui/components/form-section-title";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import { useId } from "react";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import { useFieldArray } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { InfrastructureInput } from "../../../../common/infrastructure";
|
||||
|
||||
export function InterfaceFields({
|
||||
form,
|
||||
index,
|
||||
onRemove,
|
||||
disabled,
|
||||
}: {
|
||||
form: UseFormReturn<InfrastructureInput>;
|
||||
index: number;
|
||||
onRemove: () => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("assets");
|
||||
const prefix = useId();
|
||||
const addresses = useFieldArray({ control: form.control, name: `interfaces.${index}.addresses` });
|
||||
const errors = form.formState.errors.interfaces?.[index];
|
||||
|
||||
return (
|
||||
<fieldset className="min-w-0 space-y-3 rounded-lg border p-4" disabled={disabled}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<FormSectionTitle layout="inline">
|
||||
{t("infrastructure.interfacePosition", { count: index + 1 })}
|
||||
</FormSectionTitle>
|
||||
<Button onClick={onRemove} size="sm" type="button" variant="ghost">
|
||||
{t("infrastructure.removeInterface")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor={`${prefix}-name`} required>
|
||||
{t("name")}
|
||||
</FormLabel>
|
||||
<Input
|
||||
id={`${prefix}-name`}
|
||||
{...form.register(`interfaces.${index}.name`)}
|
||||
aria-invalid={Boolean(errors?.name)}
|
||||
/>
|
||||
<p className="text-xs text-destructive">{errors?.name && t("infrastructure.invalid")}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor={`${prefix}-kind`}>{t("infrastructure.kind")}</FormLabel>
|
||||
<SelectWithAction
|
||||
disabled={disabled}
|
||||
id={`${prefix}-kind`}
|
||||
label={t("infrastructure.kind")}
|
||||
onValueChange={(value) => {
|
||||
if (value === "ethernet" || value === "wifi" || value === "other") {
|
||||
form.setValue(`interfaces.${index}.kind`, value, { shouldDirty: true });
|
||||
}
|
||||
}}
|
||||
options={["ethernet", "wifi", "other"].map((value) => ({
|
||||
value,
|
||||
label: t(`infrastructure.kinds.${value}`),
|
||||
}))}
|
||||
placeholder={t("infrastructure.kind")}
|
||||
value={form.watch(`interfaces.${index}.kind`)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor={`${prefix}-mac`}>{t("infrastructure.mac")}</FormLabel>
|
||||
<Input
|
||||
id={`${prefix}-mac`}
|
||||
{...form.register(`interfaces.${index}.mac`)}
|
||||
aria-invalid={Boolean(errors?.mac)}
|
||||
/>
|
||||
{errors?.mac && (
|
||||
<p className="text-xs text-destructive" role="alert">
|
||||
{t("infrastructure.invalidMac")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<FormSectionTitle as="h4" layout="inline">
|
||||
{t("infrastructure.addresses")}
|
||||
</FormSectionTitle>
|
||||
{addresses.fields.map((field, position) => (
|
||||
<div className="space-y-1" key={field.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="shrink-0 text-xs text-muted-foreground tabular-nums"
|
||||
>
|
||||
{position + 1}/{addresses.fields.length}
|
||||
</span>
|
||||
<label className="sr-only" htmlFor={`${prefix}-address-${position}`}>
|
||||
{t("infrastructure.addressPosition", { count: position + 1 })}
|
||||
</label>
|
||||
<Input
|
||||
id={`${prefix}-address-${position}`}
|
||||
{...form.register(`interfaces.${index}.addresses.${position}.address`)}
|
||||
aria-invalid={Boolean(errors?.addresses?.[position])}
|
||||
/>
|
||||
<Button
|
||||
aria-label={t("infrastructure.removeAddressPosition", { count: position + 1 })}
|
||||
onClick={() => addresses.remove(position)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
{errors?.addresses?.[position] && (
|
||||
<p className="text-xs text-destructive" role="alert">
|
||||
{t("infrastructure.invalidIp")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
disabled={addresses.fields.length >= 32}
|
||||
onClick={() => addresses.append({ address: "" })}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{t("infrastructure.addAddress")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<FormLabel htmlFor={`${prefix}-notes`}>{t("notes")}</FormLabel>
|
||||
<Textarea id={`${prefix}-notes`} {...form.register(`interfaces.${index}.notes`)} />
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@ -10,7 +10,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/companies/shared/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/api/domain/mailbox.test.ts src/web/access/shared/access.test.tsx src/web/mail-configurations/shared/mail.test.tsx src/api/domain/work-profile.test.ts src/web/work-profiles/shared/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printer-configurations/shared/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/application-configurations/shared/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificate-configurations/shared/certificate.test.tsx src/web/work-environments/shared/work-environment.test.tsx src/api/domain/telework-authorization.test.ts src/web/telework-authorizations/shared/telework.test.tsx src/api/domain/remote-connection.test.ts src/api/domain/process.test.ts src/web/remote-connections/shared/remote.test.tsx src/web/processes/shared/processes.test.tsx src/web/shared/initials-avatar.test.tsx src/api/infrastructure/company-logo-processor.test.ts src/web/companies/shared/company-avatar.test.tsx src/web/shared/client-contracts.test.ts src/web/workstations/detail/helpers/workstation-relations.test.ts src/web/workstations/detail/ui/workstation-detail.test.tsx src/web/workspace/shared/workspace-routes.test.ts src/web/workspace/view/helpers/sidebar-preference.test.ts src/api/domain/software-installation.test.ts src/web/software-installations/shared/software.test.tsx"
|
||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/company.aggregate.test.ts src/api/domain/people.test.ts src/web/companies/shared/companies-page.test.tsx src/api/domain/access/access.test.ts src/api/domain/mail-configuration.test.ts src/api/domain/mailbox.test.ts src/web/access/shared/access.test.tsx src/web/mail-configurations/shared/mail.test.tsx src/api/domain/work-profile.test.ts src/web/work-profiles/shared/profile.test.tsx src/api/domain/printer-configuration.test.ts src/web/printer-configurations/shared/printer.test.tsx src/api/domain/application-configuration.test.ts src/web/application-configurations/shared/application.test.tsx src/api/domain/certificate-configuration.test.ts src/web/certificate-configurations/shared/certificate.test.tsx src/web/work-environments/shared/work-environment.test.tsx src/api/domain/telework-authorization.test.ts src/web/telework-authorizations/shared/telework.test.tsx src/api/domain/remote-connection.test.ts src/api/domain/process.test.ts src/web/remote-connections/shared/remote.test.tsx src/web/processes/shared/processes.test.tsx src/web/shared/initials-avatar.test.tsx src/api/infrastructure/company-logo-processor.test.ts src/web/companies/shared/company-avatar.test.tsx src/web/shared/client-contracts.test.ts src/web/infrastructure/shared/helpers/printer-usage.test.ts src/web/workstations/detail/helpers/workstation-relations.test.ts src/web/workstations/detail/ui/workstation-detail.test.tsx src/web/workspace/shared/workspace-routes.test.ts src/web/workspace/view/helpers/sidebar-preference.test.ts src/api/domain/software-installation.test.ts src/web/software-installations/shared/software.test.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@metis/core": "workspace:*",
|
||||
|
||||
@ -825,6 +825,15 @@
|
||||
"showArchived": "Also show archived sites",
|
||||
"delete": "Delete site",
|
||||
"deleteDescription": "“{{name}}” will be permanently deleted. This is only allowed if it has no associated records, including historical references. If it is in use, you can archive it."
|
||||
},
|
||||
"infrastructureUsage": {
|
||||
"error": "Printer usage could not be loaded.",
|
||||
"empty": "There are no open printing configurations for this asset.",
|
||||
"incomplete": "Some configurations have an unresolved profile. Review their history.",
|
||||
"default": "Default",
|
||||
"review": "Configuration needs review.",
|
||||
"history": "Historical configurations ({{count}})",
|
||||
"notice": "Usage documented through profiles; this is not real-time detected activity."
|
||||
}
|
||||
},
|
||||
"access": {
|
||||
|
||||
@ -825,6 +825,15 @@
|
||||
"showArchived": "Mostrar también las sedes archivadas",
|
||||
"delete": "Eliminar sede",
|
||||
"deleteDescription": "Se eliminará «{{name}}» de forma permanente. Solo se permite si no tiene ningún registro asociado, incluidas referencias históricas. Si está en uso, puedes archivarla."
|
||||
},
|
||||
"infrastructureUsage": {
|
||||
"error": "No se ha podido consultar el uso de la impresora.",
|
||||
"empty": "No hay configuraciones de impresión abiertas para este activo.",
|
||||
"incomplete": "Hay configuraciones cuyo perfil no se ha podido resolver. Revisa el historial.",
|
||||
"default": "Predeterminada",
|
||||
"review": "Configuración pendiente de revisión.",
|
||||
"history": "Configuraciones históricas ({{count}})",
|
||||
"notice": "Uso documentado a través de perfiles; no representa actividad detectada en tiempo real."
|
||||
}
|
||||
},
|
||||
"access": {
|
||||
|
||||
@ -14,6 +14,7 @@ import {
|
||||
MailIcon,
|
||||
MapPinIcon,
|
||||
MonitorIcon,
|
||||
NetworkIcon,
|
||||
PersonIcon,
|
||||
PrinterIcon,
|
||||
WorkProfileIcon,
|
||||
@ -116,6 +117,7 @@ export function CompanyDetail(props: CompanyDetailProps) {
|
||||
["workstations", assetTranslation("workstations"), MonitorIcon],
|
||||
["profiles", t("profiles.title"), WorkProfileIcon],
|
||||
["inventory", assetTranslation("inventory"), BoxIcon],
|
||||
["infrastructure", assetTranslation("infrastructure.title"), NetworkIcon],
|
||||
["resources", resourceTranslation("inventory"), LayersIcon],
|
||||
["departments", t("departments"), BriefcaseIcon],
|
||||
["sites", t("sites"), MapPinIcon],
|
||||
|
||||
@ -2,6 +2,7 @@ import { AssetInventoryPage, WorkstationsPage } from "@metis/assets/client";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { CompanyDTO } from "../../../../common";
|
||||
import { AssetPrinterUsage } from "../../../infrastructure/shared/ui/asset-printer-usage";
|
||||
import { WorkstationDetailPage } from "../../../workstations/detail/ui/workstation-detail-page";
|
||||
|
||||
import { InventoryQuickCreateDialog } from "./inventory-quick-create-dialog";
|
||||
@ -24,6 +25,7 @@ export function CompanyInventory({
|
||||
onCreated: (id: string) => void;
|
||||
}>();
|
||||
const lookups = {
|
||||
renderUsage: (assetId: string) => <AssetPrinterUsage assetId={assetId} company={company} />,
|
||||
sites: company.sites,
|
||||
people: company.people,
|
||||
onCreateSite: (onCreated: (id: string) => void) => setQuick({ kind: "site", onCreated }),
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { AssetCatalogPage } from "@metis/assets/client";
|
||||
import { AssetCatalogPage, InfrastructurePage } from "@metis/assets/client";
|
||||
import { useBrowserSearchParams, useCreateNavigation } from "@metis/core/client";
|
||||
import { ResourceCatalogPage, ResourcesPage } from "@metis/resources/client";
|
||||
|
||||
@ -48,6 +48,8 @@ export function CompanySection({
|
||||
const profileId = searchParams.get("profileId") ?? undefined;
|
||||
|
||||
switch (tab) {
|
||||
case "infrastructure":
|
||||
return <InfrastructurePage companyId={company.id} />;
|
||||
case "overview":
|
||||
return <CompanyOverview company={company} />;
|
||||
case "catalogs":
|
||||
|
||||
@ -12,6 +12,7 @@ export type CompanyTab =
|
||||
| "resources"
|
||||
| "assets"
|
||||
| "inventory"
|
||||
| "infrastructure"
|
||||
| "workstations"
|
||||
| "certificates"
|
||||
| "applications"
|
||||
|
||||
@ -0,0 +1,98 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
|
||||
import { PrinterConfigurationSchema } from "../../../../common/printer-configurations";
|
||||
import { WorkProfileSchema } from "../../../../common/work-profiles";
|
||||
|
||||
import { printerUsage } from "./printer-usage";
|
||||
|
||||
const id = () => UniqueID.generateNewID().toPrimitive();
|
||||
|
||||
const companyId = id();
|
||||
const actorId = id();
|
||||
const printerId = id();
|
||||
const a1 = id();
|
||||
const a2 = id();
|
||||
|
||||
function profile(assetId: string) {
|
||||
const values = {
|
||||
name: "Perfil",
|
||||
assetId,
|
||||
personId: id(),
|
||||
accountId: null,
|
||||
active: true,
|
||||
notes: null,
|
||||
};
|
||||
|
||||
return WorkProfileSchema.parse({
|
||||
id: id(),
|
||||
companyId,
|
||||
values,
|
||||
needsReview: false,
|
||||
history: [
|
||||
{ id: id(), values, actorId, actorName: "Técnico", recordedAt: "2026-10-17T10:00:00.000Z" },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function configuration(
|
||||
profileId: string,
|
||||
state: "configured" | "pending" | "removal_pending" | "removed" | "never_configured"
|
||||
) {
|
||||
const values = {
|
||||
profileId,
|
||||
printerId,
|
||||
connection: "network",
|
||||
isDefault: false,
|
||||
state,
|
||||
notes: null,
|
||||
};
|
||||
|
||||
return PrinterConfigurationSchema.parse({
|
||||
id: id(),
|
||||
companyId,
|
||||
values,
|
||||
needsReview: false,
|
||||
history: [
|
||||
{ id: id(), values, actorId, actorName: "Técnico", recordedAt: "2026-10-17T10:00:00.000Z" },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
test("uso de impresora: agrupa perfiles de un mismo equipo y separa pendientes e historial", () => {
|
||||
const one = profile(a1);
|
||||
const two = profile(a1);
|
||||
const three = profile(a2);
|
||||
const configurations = [
|
||||
configuration(one.id, "configured"),
|
||||
configuration(two.id, "pending"),
|
||||
configuration(three.id, "removal_pending"),
|
||||
configuration(one.id, "removed"),
|
||||
configuration(two.id, "never_configured"),
|
||||
];
|
||||
const usage = printerUsage(
|
||||
printerId,
|
||||
configurations,
|
||||
[one, two, three],
|
||||
[
|
||||
{ id: a1, values: { name: "A1" } },
|
||||
{ id: a2, values: { name: "A2" } },
|
||||
]
|
||||
);
|
||||
|
||||
assert.equal(usage.groups.length, 2);
|
||||
assert.equal(usage.groups[0]?.configurations.length, 2);
|
||||
assert.equal(usage.groups[1]?.configurations[0]?.configuration.values.state, "removal_pending");
|
||||
assert.equal(usage.history.length, 2);
|
||||
assert.equal(printerUsage(id(), configurations, [one, two, three], []).groups.length, 0);
|
||||
});
|
||||
|
||||
test("uso de impresora: referencias sin resolver no se presentan como ausencia de uso", () => {
|
||||
const missing = configuration(id(), "configured");
|
||||
const usage = printerUsage(printerId, [missing], [], []);
|
||||
|
||||
assert.equal(usage.groups.length, 0);
|
||||
assert.equal(usage.unresolved.length, 1);
|
||||
});
|
||||
@ -0,0 +1,57 @@
|
||||
import type { PrinterConfigurationDTO } from "../../../../common/printer-configurations";
|
||||
import type { WorkProfileDTO } from "../../../../common/work-profiles";
|
||||
|
||||
/** Agrupa el uso registrado por equipo; conserva estados pendientes y separa el historial terminal. */
|
||||
export function printerUsage(
|
||||
assetId: string,
|
||||
configurations: readonly PrinterConfigurationDTO[],
|
||||
profiles: readonly WorkProfileDTO[],
|
||||
assets: readonly { id: string; values: { name: string } }[]
|
||||
) {
|
||||
const groups = new Map<
|
||||
string,
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
configurations: { configuration: PrinterConfigurationDTO; profile: WorkProfileDTO }[];
|
||||
}
|
||||
>();
|
||||
const history = configurations.filter(
|
||||
(item) =>
|
||||
item.values.printerId === assetId &&
|
||||
["removed", "never_configured"].includes(item.values.state)
|
||||
);
|
||||
const unresolved = configurations.filter(
|
||||
(item) =>
|
||||
item.values.printerId === assetId &&
|
||||
!["removed", "never_configured"].includes(item.values.state) &&
|
||||
!profiles.some((profile) => profile.id === item.values.profileId)
|
||||
);
|
||||
|
||||
for (const item of configurations.filter(
|
||||
(entry) =>
|
||||
entry.values.printerId === assetId &&
|
||||
!["removed", "never_configured"].includes(entry.values.state)
|
||||
)) {
|
||||
const profile = profiles.find((entry) => entry.id === item.values.profileId);
|
||||
|
||||
if (!profile) {
|
||||
continue;
|
||||
}
|
||||
const id = profile.values.assetId;
|
||||
const group = groups.get(id) ?? {
|
||||
id,
|
||||
name: assets.find((entry) => entry.id === id)?.values.name ?? "—",
|
||||
configurations: [],
|
||||
};
|
||||
|
||||
group.configurations.push({ configuration: item, profile });
|
||||
groups.set(id, group);
|
||||
}
|
||||
|
||||
return {
|
||||
groups: [...groups.values()].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
history,
|
||||
unresolved,
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,118 @@
|
||||
import { inventoryApi, inventoryKeys } from "@metis/assets/client";
|
||||
import { BrowserLink, useListQuery } from "@metis/core/client";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CompanyDTO } from "../../../../common";
|
||||
import { printerApi, printerKeys } from "../../../printer-configurations/shared/api";
|
||||
import { profileApi, profileKeys } from "../../../work-profiles/shared/api";
|
||||
import { printerUsage } from "../helpers/printer-usage";
|
||||
|
||||
export function AssetPrinterUsage({ company, assetId }: { company: CompanyDTO; assetId: string }) {
|
||||
const { t } = useTranslation("companies");
|
||||
const printers = useListQuery({
|
||||
queryKey: printerKeys.company(company.id),
|
||||
queryFn: () => printerApi.get(company.id),
|
||||
});
|
||||
const profiles = useListQuery({
|
||||
queryKey: profileKeys.company(company.id),
|
||||
queryFn: () => profileApi.get(company.id),
|
||||
});
|
||||
const inventory = useListQuery({
|
||||
queryKey: inventoryKeys.company(company.id),
|
||||
queryFn: () => inventoryApi.get(company.id),
|
||||
});
|
||||
const failed = printers.isError || profiles.isError || inventory.isError;
|
||||
const usage = printerUsage(
|
||||
assetId,
|
||||
printers.data?.configurations ?? [],
|
||||
profiles.data?.profiles ?? [],
|
||||
inventory.data?.assets ?? []
|
||||
);
|
||||
|
||||
if (printers.isPending || profiles.isPending || inventory.isPending) {
|
||||
return <p role="status">{t("loading")}</p>;
|
||||
}
|
||||
if (failed) {
|
||||
return (
|
||||
<div>
|
||||
<p role="alert">{t("infrastructureUsage.error")}</p>
|
||||
<Button
|
||||
onClick={() => {
|
||||
Promise.all([printers.refetch(), profiles.refetch(), inventory.refetch()]).catch(
|
||||
() => undefined
|
||||
);
|
||||
}}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{!usage.groups.length && (
|
||||
<p className="text-sm text-muted-foreground">{t("infrastructureUsage.empty")}</p>
|
||||
)}
|
||||
{usage.unresolved.length > 0 && <p role="alert">{t("infrastructureUsage.incomplete")}</p>}
|
||||
<ul className="space-y-3">
|
||||
{usage.groups.map((group) => (
|
||||
<li className="rounded-md border p-3 text-sm" key={group.id}>
|
||||
<BrowserLink
|
||||
className="font-medium"
|
||||
href={`/companies/${company.id}/assets/${group.id}`}
|
||||
>
|
||||
{group.name}
|
||||
</BrowserLink>
|
||||
<ul className="mt-2 space-y-2">
|
||||
{group.configurations.map(({ profile, configuration }) => (
|
||||
<li key={configuration.id}>
|
||||
<BrowserLink href={`/companies/${company.id}/work-profiles/${profile.id}`}>
|
||||
{profile.values.name}
|
||||
</BrowserLink>{" "}
|
||||
·{" "}
|
||||
{company.people.find((person) => person.id === profile.values.personId)?.name ??
|
||||
"—"}
|
||||
<p className="text-muted-foreground">
|
||||
{t(`mail.${configuration.values.state}`)} ·{" "}
|
||||
{t(`printers.${configuration.values.connection}`)}
|
||||
{configuration.values.isDefault ? ` · ${t("infrastructureUsage.default")}` : ""}
|
||||
</p>
|
||||
{(!profile.values.active || configuration.needsReview) && (
|
||||
<p className="text-amber-700 dark:text-amber-400">
|
||||
{t("infrastructureUsage.review")}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{usage.history.length > 0 && (
|
||||
<details>
|
||||
<summary className="cursor-pointer text-sm">
|
||||
{t("infrastructureUsage.history", { count: usage.history.length })}
|
||||
</summary>
|
||||
<ul className="mt-2 space-y-2">
|
||||
{usage.history.map((item) => (
|
||||
<li className="text-sm" key={item.id}>
|
||||
<BrowserLink
|
||||
href={`/companies/${company.id}/work-profiles/${item.values.profileId}`}
|
||||
>
|
||||
{profiles.data?.profiles.find((profile) => profile.id === item.values.profileId)
|
||||
?.values.name ?? "—"}
|
||||
</BrowserLink>{" "}
|
||||
· {t(`mail.${item.values.state}`)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">{t("infrastructureUsage.notice")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -8,6 +8,7 @@ import type { GlobalCatalog, WorkspaceLocation } from "../types/workspace-locati
|
||||
export const COMPANY_SECTION_PATHS = {
|
||||
overview: "",
|
||||
inventory: "assets",
|
||||
infrastructure: "infrastructure",
|
||||
resources: "resources",
|
||||
workstations: "workstations",
|
||||
profiles: "work-profiles",
|
||||
|
||||
@ -89,6 +89,7 @@ test("todas las secciones de compañía tienen una ruta propia y el índice es e
|
||||
for (const section of [
|
||||
"overview",
|
||||
"inventory",
|
||||
"infrastructure",
|
||||
"resources",
|
||||
"workstations",
|
||||
"profiles",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user