Infraestructura
This commit is contained in:
parent
c7fa4ad291
commit
4bfbb30be0
@ -6,7 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node --env-file=../../.env --import tsx --watch src/index.ts",
|
"dev": "node --env-file=../../.env --import tsx --watch src/index.ts",
|
||||||
"typecheck": "tsc -p tsconfig.json",
|
"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",
|
"build": "tsup --config tsup.config.ts",
|
||||||
"start": "node --env-file=../../.env dist/index.js"
|
"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.
|
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
|
Las altas, ediciones y cambios de estado o borrado reutilizan
|
||||||
`resourceRelatedQueryKeys` para actualizar inventario y listados consumidores.
|
`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.
|
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.
|
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).
|
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": {
|
"scripts": {
|
||||||
"typecheck": "tsc -p tsconfig.json",
|
"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": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "5.2.2",
|
"@hookform/resolvers": "5.2.2",
|
||||||
|
|||||||
@ -13,6 +13,7 @@ export function buildCatalogResponse(
|
|||||||
id: item.id.toPrimitive(),
|
id: item.id.toPrimitive(),
|
||||||
companyId: item.companyId.getOrUndefined()?.toPrimitive() ?? null,
|
companyId: item.companyId.getOrUndefined()?.toPrimitive() ?? null,
|
||||||
builtin: item.stockKey.getOrUndefined() !== undefined,
|
builtin: item.stockKey.getOrUndefined() !== undefined,
|
||||||
|
builtinKey: item.stockKey.getOrUndefined() ?? null,
|
||||||
...(item.stockKey.getOrUndefined() === "field:brand"
|
...(item.stockKey.getOrUndefined() === "field:brand"
|
||||||
? { referenceCatalog: "brands" as const }
|
? { 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 { RelationshipModel } from "./asset-relationship.model";
|
||||||
import { AssetRelationshipPersistenceMapper } from "./asset-relationship-persistence.mapper";
|
import { AssetRelationshipPersistenceMapper } from "./asset-relationship-persistence.mapper";
|
||||||
import { loadWorkstations } from "./load-workstations";
|
import { loadWorkstations } from "./load-workstations";
|
||||||
|
import { removeAssetInfrastructure } from "./remove-asset-infrastructure";
|
||||||
import { readBrands } from "./sequelize-brand.repository";
|
import { readBrands } from "./sequelize-brand.repository";
|
||||||
import { WorkstationAssetModel } from "./workstation.model";
|
import { WorkstationAssetModel } from "./workstation.model";
|
||||||
import { WorkstationPersistenceMapper } from "./workstation-persistence.mapper";
|
import { WorkstationPersistenceMapper } from "./workstation-persistence.mapper";
|
||||||
@ -244,6 +245,7 @@ export class SequelizeAssetRepository implements IAssetRepository {
|
|||||||
{ type: QueryTypes.SELECT, transaction }
|
{ type: QueryTypes.SELECT, transaction }
|
||||||
);
|
);
|
||||||
const ownedTables = new Set([
|
const ownedTables = new Set([
|
||||||
|
"asset_infrastructure",
|
||||||
"asset_characteristics",
|
"asset_characteristics",
|
||||||
"asset_revision_characteristics",
|
"asset_revision_characteristics",
|
||||||
"asset_revisions",
|
"asset_revisions",
|
||||||
@ -270,6 +272,7 @@ export class SequelizeAssetRepository implements IAssetRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async removeInventory(assetId: string, companyId: string, transaction: Transaction) {
|
private async removeInventory(assetId: string, companyId: string, transaction: Transaction) {
|
||||||
|
await removeAssetInfrastructure(this.database, companyId, assetId, transaction);
|
||||||
for (const table of [
|
for (const table of [
|
||||||
"asset_revision_characteristics",
|
"asset_revision_characteristics",
|
||||||
"asset_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 { AssetCatalogUseCases } from "./application/asset-catalog.use-cases";
|
||||||
import { AssetRelationshipUseCases } from "./application/asset-relationship.use-cases";
|
import { AssetRelationshipUseCases } from "./application/asset-relationship.use-cases";
|
||||||
import { BrandUseCases } from "./application/brand.use-cases";
|
import { BrandUseCases } from "./application/brand.use-cases";
|
||||||
|
import { InfrastructureUseCases } from "./application/infrastructure.use-cases";
|
||||||
import { SupportUseCases } from "./application/support.use-cases";
|
import { SupportUseCases } from "./application/support.use-cases";
|
||||||
import { WorkstationUseCases } from "./application/workstation.use-cases";
|
import { WorkstationUseCases } from "./application/workstation.use-cases";
|
||||||
import { seedAssetCatalog } from "./domain/asset-catalog-seed";
|
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 { registerAssetCatalogRoutes } from "./infrastructure/http/asset-catalog.routes";
|
||||||
import { registerRelationshipRoutes } from "./infrastructure/http/asset-relationship.routes";
|
import { registerRelationshipRoutes } from "./infrastructure/http/asset-relationship.routes";
|
||||||
import { registerBrandRoutes } from "./infrastructure/http/brand.routes";
|
import { registerBrandRoutes } from "./infrastructure/http/brand.routes";
|
||||||
|
import { registerInfrastructureRoutes } from "./infrastructure/http/infrastructure.routes";
|
||||||
import { registerSupportRoutes } from "./infrastructure/http/support.routes";
|
import { registerSupportRoutes } from "./infrastructure/http/support.routes";
|
||||||
import { registerWorkstationRoutes } from "./infrastructure/http/workstation.routes";
|
import { registerWorkstationRoutes } from "./infrastructure/http/workstation.routes";
|
||||||
import { migrateInventory } from "./infrastructure/persistence/asset.model";
|
import { migrateInventory } from "./infrastructure/persistence/asset.model";
|
||||||
@ -31,9 +33,11 @@ import {
|
|||||||
import { initializeAssetInventorySchema } from "./infrastructure/persistence/asset-relational-schema";
|
import { initializeAssetInventorySchema } from "./infrastructure/persistence/asset-relational-schema";
|
||||||
import { migrateRelationships } from "./infrastructure/persistence/asset-relationship.model";
|
import { migrateRelationships } from "./infrastructure/persistence/asset-relationship.model";
|
||||||
import { migrateBrands } from "./infrastructure/persistence/brand-migration";
|
import { migrateBrands } from "./infrastructure/persistence/brand-migration";
|
||||||
|
import { migrateInfrastructure } from "./infrastructure/persistence/infrastructure-migration";
|
||||||
import { SequelizeAssetRepository } from "./infrastructure/persistence/sequelize-asset.repository";
|
import { SequelizeAssetRepository } from "./infrastructure/persistence/sequelize-asset.repository";
|
||||||
import { SequelizeAssetCatalogRepository } from "./infrastructure/persistence/sequelize-asset-catalog.repository";
|
import { SequelizeAssetCatalogRepository } from "./infrastructure/persistence/sequelize-asset-catalog.repository";
|
||||||
import { SequelizeBrandRepository } from "./infrastructure/persistence/sequelize-brand.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 { SequelizeSupportRepository } from "./infrastructure/persistence/sequelize-support.repository";
|
||||||
import { migrateSupport } from "./infrastructure/persistence/support-migration";
|
import { migrateSupport } from "./infrastructure/persistence/support-migration";
|
||||||
import { migrateWorkstations } from "./infrastructure/persistence/workstation.model";
|
import { migrateWorkstations } from "./infrastructure/persistence/workstation.model";
|
||||||
@ -65,6 +69,10 @@ export async function createAssetModule(database: Sequelize, authenticate: Reque
|
|||||||
await initializeAssetInventorySchema(database);
|
await initializeAssetInventorySchema(database);
|
||||||
await enforceAssetRelationalConstraints(database);
|
await enforceAssetRelationalConstraints(database);
|
||||||
await migrateSupport(database);
|
await migrateSupport(database);
|
||||||
|
await migrateInfrastructure(database);
|
||||||
|
const infrastructure = new InfrastructureUseCases(
|
||||||
|
new SequelizeInfrastructureRepository(database)
|
||||||
|
);
|
||||||
const support = new SupportUseCases(new SequelizeSupportRepository(database));
|
const support = new SupportUseCases(new SequelizeSupportRepository(database));
|
||||||
const assetRepository = new SequelizeAssetRepository(database);
|
const assetRepository = new SequelizeAssetRepository(database);
|
||||||
const inventory = new AssetUseCases(assetRepository);
|
const inventory = new AssetUseCases(assetRepository);
|
||||||
@ -101,6 +109,7 @@ export async function createAssetModule(database: Sequelize, authenticate: Reque
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
register: (app: Parameters<typeof registerAssetCatalogRoutes>[0]) => {
|
register: (app: Parameters<typeof registerAssetCatalogRoutes>[0]) => {
|
||||||
|
registerInfrastructureRoutes(app, infrastructure, authenticate);
|
||||||
registerBrandRoutes(app, brands, authenticate);
|
registerBrandRoutes(app, brands, authenticate);
|
||||||
registerAssetCatalogRoutes(app, useCases, authenticate);
|
registerAssetCatalogRoutes(app, useCases, authenticate);
|
||||||
registerAssetRoutes(app, inventory, authenticate);
|
registerAssetRoutes(app, inventory, authenticate);
|
||||||
|
|||||||
@ -20,6 +20,7 @@ export const CatalogItemSchema = z.strictObject({
|
|||||||
id: z.uuid(),
|
id: z.uuid(),
|
||||||
companyId: z.uuid().nullable(),
|
companyId: z.uuid().nullable(),
|
||||||
builtin: z.boolean(),
|
builtin: z.boolean(),
|
||||||
|
builtinKey: z.string().nullable().optional(),
|
||||||
referenceCatalog: z.literal("brands").optional(),
|
referenceCatalog: z.literal("brands").optional(),
|
||||||
active: z.boolean(),
|
active: z.boolean(),
|
||||||
effectiveActive: z.boolean(),
|
effectiveActive: z.boolean(),
|
||||||
@ -39,11 +40,8 @@ export type CatalogResponseDTO = z.infer<typeof CatalogResponseSchema>;
|
|||||||
export type SaveDefinitionResponseDTO = z.infer<typeof SaveDefinitionResponseSchema>;
|
export type SaveDefinitionResponseDTO = z.infer<typeof SaveDefinitionResponseSchema>;
|
||||||
|
|
||||||
export * from "./brands";
|
export * from "./brands";
|
||||||
|
export * from "./infrastructure";
|
||||||
export * from "./inventory";
|
export * from "./inventory";
|
||||||
|
|
||||||
export * from "./relationships";
|
export * from "./relationships";
|
||||||
|
|
||||||
export * from "./support";
|
export * from "./support";
|
||||||
|
|
||||||
export * from "./workstations";
|
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.",
|
"templateDescription": "Select the template that defines the asset characteristics.",
|
||||||
"fieldsDescription": "Complete the template details. Fields marked with * are required.",
|
"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."
|
"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.",
|
"templateDescription": "Selecciona la plantilla que define las características del activo.",
|
||||||
"fieldsDescription": "Completa los datos de la plantilla. Los campos marcados con * son obligatorios.",
|
"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."
|
"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 { FormSectionTitle } from "@repo/rdx-ui/components/form-section-title";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { AssetInfrastructureSections } from "../../../infrastructure/shared/ui/asset-infrastructure-sections";
|
||||||
import { formatAssetRegistrationDate } from "../../shared/helpers/format-asset-registration-date";
|
import { formatAssetRegistrationDate } from "../../shared/helpers/format-asset-registration-date";
|
||||||
import { useAssetDetailController } from "../controllers";
|
import { useAssetDetailController } from "../controllers";
|
||||||
import type { AssetDetailProps } from "../types";
|
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>
|
<p className="whitespace-pre-wrap break-words text-sm">{asset.values.notes}</p>
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
)}
|
)}
|
||||||
|
<AssetInfrastructureSections
|
||||||
|
asset={asset}
|
||||||
|
compact={compact}
|
||||||
|
renderUsage={lookups.renderUsage}
|
||||||
|
/>
|
||||||
{children}
|
{children}
|
||||||
{!compact && (
|
{!compact && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -75,6 +75,8 @@ export function AssetSelectionPanel({
|
|||||||
<Accordion
|
<Accordion
|
||||||
defaultValue={[
|
defaultValue={[
|
||||||
"assignment",
|
"assignment",
|
||||||
|
"infrastructure",
|
||||||
|
"usage",
|
||||||
"characteristics",
|
"characteristics",
|
||||||
"ungrouped",
|
"ungrouped",
|
||||||
"notes",
|
"notes",
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import type { CatalogItemDTO } from "../../../../common";
|
import type { CatalogItemDTO } from "../../../../common";
|
||||||
import type { AssetDTO } from "../../../../common/inventory";
|
import type { AssetDTO } from "../../../../common/inventory";
|
||||||
|
|
||||||
export type InventoryLookups = {
|
export type InventoryLookups = {
|
||||||
|
renderUsage?: (assetId: string) => ReactNode;
|
||||||
sites: readonly {
|
sites: readonly {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@ -4,6 +4,11 @@ export { AssetSummaryCard } from "./assets/shared/ui/asset-summary-card";
|
|||||||
export { BrandCatalogPage } from "./brands";
|
export { BrandCatalogPage } from "./brands";
|
||||||
export { AssetCatalogPage } from "./catalog";
|
export { AssetCatalogPage } from "./catalog";
|
||||||
export { catalogApi, catalogKeys } from "./catalog/shared/api";
|
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 { assetsEnglish, assetsSpanish } from "./shared";
|
||||||
export { inventoryApi } from "./shared/api";
|
export { inventoryApi } from "./shared/api";
|
||||||
export { inventoryKeys } from "./shared/queries";
|
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": {
|
"scripts": {
|
||||||
"typecheck": "tsc -p tsconfig.json",
|
"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": {
|
"dependencies": {
|
||||||
"@metis/core": "workspace:*",
|
"@metis/core": "workspace:*",
|
||||||
|
|||||||
@ -825,6 +825,15 @@
|
|||||||
"showArchived": "Also show archived sites",
|
"showArchived": "Also show archived sites",
|
||||||
"delete": "Delete site",
|
"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."
|
"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": {
|
"access": {
|
||||||
|
|||||||
@ -825,6 +825,15 @@
|
|||||||
"showArchived": "Mostrar también las sedes archivadas",
|
"showArchived": "Mostrar también las sedes archivadas",
|
||||||
"delete": "Eliminar sede",
|
"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."
|
"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": {
|
"access": {
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
MailIcon,
|
MailIcon,
|
||||||
MapPinIcon,
|
MapPinIcon,
|
||||||
MonitorIcon,
|
MonitorIcon,
|
||||||
|
NetworkIcon,
|
||||||
PersonIcon,
|
PersonIcon,
|
||||||
PrinterIcon,
|
PrinterIcon,
|
||||||
WorkProfileIcon,
|
WorkProfileIcon,
|
||||||
@ -116,6 +117,7 @@ export function CompanyDetail(props: CompanyDetailProps) {
|
|||||||
["workstations", assetTranslation("workstations"), MonitorIcon],
|
["workstations", assetTranslation("workstations"), MonitorIcon],
|
||||||
["profiles", t("profiles.title"), WorkProfileIcon],
|
["profiles", t("profiles.title"), WorkProfileIcon],
|
||||||
["inventory", assetTranslation("inventory"), BoxIcon],
|
["inventory", assetTranslation("inventory"), BoxIcon],
|
||||||
|
["infrastructure", assetTranslation("infrastructure.title"), NetworkIcon],
|
||||||
["resources", resourceTranslation("inventory"), LayersIcon],
|
["resources", resourceTranslation("inventory"), LayersIcon],
|
||||||
["departments", t("departments"), BriefcaseIcon],
|
["departments", t("departments"), BriefcaseIcon],
|
||||||
["sites", t("sites"), MapPinIcon],
|
["sites", t("sites"), MapPinIcon],
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { AssetInventoryPage, WorkstationsPage } from "@metis/assets/client";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import type { CompanyDTO } from "../../../../common";
|
import type { CompanyDTO } from "../../../../common";
|
||||||
|
import { AssetPrinterUsage } from "../../../infrastructure/shared/ui/asset-printer-usage";
|
||||||
import { WorkstationDetailPage } from "../../../workstations/detail/ui/workstation-detail-page";
|
import { WorkstationDetailPage } from "../../../workstations/detail/ui/workstation-detail-page";
|
||||||
|
|
||||||
import { InventoryQuickCreateDialog } from "./inventory-quick-create-dialog";
|
import { InventoryQuickCreateDialog } from "./inventory-quick-create-dialog";
|
||||||
@ -24,6 +25,7 @@ export function CompanyInventory({
|
|||||||
onCreated: (id: string) => void;
|
onCreated: (id: string) => void;
|
||||||
}>();
|
}>();
|
||||||
const lookups = {
|
const lookups = {
|
||||||
|
renderUsage: (assetId: string) => <AssetPrinterUsage assetId={assetId} company={company} />,
|
||||||
sites: company.sites,
|
sites: company.sites,
|
||||||
people: company.people,
|
people: company.people,
|
||||||
onCreateSite: (onCreated: (id: string) => void) => setQuick({ kind: "site", onCreated }),
|
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 { useBrowserSearchParams, useCreateNavigation } from "@metis/core/client";
|
||||||
import { ResourceCatalogPage, ResourcesPage } from "@metis/resources/client";
|
import { ResourceCatalogPage, ResourcesPage } from "@metis/resources/client";
|
||||||
|
|
||||||
@ -48,6 +48,8 @@ export function CompanySection({
|
|||||||
const profileId = searchParams.get("profileId") ?? undefined;
|
const profileId = searchParams.get("profileId") ?? undefined;
|
||||||
|
|
||||||
switch (tab) {
|
switch (tab) {
|
||||||
|
case "infrastructure":
|
||||||
|
return <InfrastructurePage companyId={company.id} />;
|
||||||
case "overview":
|
case "overview":
|
||||||
return <CompanyOverview company={company} />;
|
return <CompanyOverview company={company} />;
|
||||||
case "catalogs":
|
case "catalogs":
|
||||||
|
|||||||
@ -12,6 +12,7 @@ export type CompanyTab =
|
|||||||
| "resources"
|
| "resources"
|
||||||
| "assets"
|
| "assets"
|
||||||
| "inventory"
|
| "inventory"
|
||||||
|
| "infrastructure"
|
||||||
| "workstations"
|
| "workstations"
|
||||||
| "certificates"
|
| "certificates"
|
||||||
| "applications"
|
| "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 = {
|
export const COMPANY_SECTION_PATHS = {
|
||||||
overview: "",
|
overview: "",
|
||||||
inventory: "assets",
|
inventory: "assets",
|
||||||
|
infrastructure: "infrastructure",
|
||||||
resources: "resources",
|
resources: "resources",
|
||||||
workstations: "workstations",
|
workstations: "workstations",
|
||||||
profiles: "work-profiles",
|
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 [
|
for (const section of [
|
||||||
"overview",
|
"overview",
|
||||||
"inventory",
|
"inventory",
|
||||||
|
"infrastructure",
|
||||||
"resources",
|
"resources",
|
||||||
"workstations",
|
"workstations",
|
||||||
"profiles",
|
"profiles",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user