Compare commits

..

135 Commits

Author SHA1 Message Date
Juan Manuel Bardallo cf1e635aca feat(plugin): add activeOsOnly and excludeClientStatus filtering for plugin commands
oggui-debian-package/pipeline/head This commit looks good Details
oggui-debian-package/pipeline/tag This commit looks good Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2026-11-04 09:11:43 +01:00
Juan Manuel Bardallo a5e565aced refactor(plugin): modularize PluginService into specialized sub-services and eliminate badge duplication
oggui-debian-package/pipeline/head This commit looks good Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/tag This commit looks good Details
2026-11-01 20:49:45 +01:00
Juan Manuel Bardallo bfc7e07e2a feat: declarative plugin command status filtering and dynamic action labels v1.25.0
oggui-debian-package/pipeline/head This commit looks good Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/tag This commit looks good Details
2026-11-01 20:38:49 +01:00
Juan Manuel Bardallo dc4186ff4d fix(plugins): gestion nativa de estado por disco y particion con contrato estandarizado (v1.24.7)
oggui-debian-package/pipeline/head This commit looks good Details
oggui-debian-package/pipeline/tag This commit looks good Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2026-11-01 11:24:01 +01:00
Juan Manuel Bardallo 1ee3b6d465 fix(plugins): desacoplamiento total en PluginService y gestion agnostica de estado de particiones (v1.24.6)
oggui-debian-package/pipeline/head This commit looks good Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/tag This commit looks good Details
2026-10-31 13:04:38 +01:00
Juan Manuel Bardallo e8259bb1fc docs: consolidacion de changelog en 1.24.5 tras eliminacion de tags intermedios
oggui-debian-package/pipeline/head This commit looks good Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/tag This commit looks good Details
2026-10-30 20:59:15 +01:00
Juan Manuel Bardallo 8073408560 feat(plugins): soporte de tooltipFields y desacoplamiento total en PluginService (v1.24.5)
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
oggui-debian-package/pipeline/tag This commit looks good Details
2026-10-30 20:34:12 +01:00
Juan Manuel Bardallo 5257e9e63e fix(groups): eliminacion total de sondeo periodico manteniendo arquitectura reactiva mercure (v1.24.5)
oggui-debian-package/pipeline/tag This commit looks good Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
2026-10-30 20:16:37 +01:00
Juan Manuel Bardallo ac1efaa08a refactor: estandarizacion en remaining_minutes y simplificacion de metadatos (v1.24.4)
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
oggui-debian-package/pipeline/tag This commit looks good Details
2026-10-30 20:13:29 +01:00
Juan Manuel Bardallo 4788e1f7bc fix(plugins): sincronizacion reciproca de tiempo restante y auto-conexion mercure (v1.24.3)
oggui-debian-package/pipeline/head This commit looks good Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/tag This commit looks good Details
2026-10-30 15:09:57 +01:00
Juan Manuel Bardallo 8a82c37d48 fix(tooltips): actualizacion reactiva de tooltips y badges de plugins en tiempo real (v1.24.2)
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
oggui-debian-package/pipeline/tag This commit looks good Details
2026-10-30 14:24:20 +01:00
Juan Manuel Bardallo dbea3d2cd6 fix(plugins): deteccion dinamica de lenguaje y sintaxis en editor de scripts (v1.24.1)
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
oggui-debian-package/pipeline/tag This commit looks good Details
2026-10-30 08:19:41 +01:00
Juan Manuel Bardallo b2237d7630 i18n: anadir traducciones en es, en y cat para gestion de roles y funciones en plugins (v1.24.0)
oggui-debian-package/pipeline/tag This commit looks good Details
2026-10-28 20:18:29 +01:00
Juan Manuel Bardallo b6007ecf8a feat: control de acceso y visibilidad de funciones de plugins por rol (v1.24.0) 2026-10-28 20:12:50 +01:00
Juan Manuel Bardallo 5d206432a5 feat: enrich plugin badge tooltips with dynamic client metadata (v1.23.0) 2026-10-27 09:32:33 +01:00
Juan Manuel Bardallo f8bd02c316 feat(commands): add generic plugin command parameter modal and confirmation dialog (1.22.0) 2026-10-27 00:52:59 +01:00
Juan Manuel Bardallo 4d755cca1f refactor(plugins): decouple oggui completely and restore agnostic dynamic manifest & i18n loading (1.21.1) 2026-10-27 00:30:06 +01:00
Juan Manuel Bardallo b169748c98 fix(plugins): ensure records subtab fallback and provide complete schema translations (1.21.1) 2026-10-26 22:06:10 +01:00
Juan Manuel Bardallo c178c21031 feat: add generic tabular records and history subtab for plugins 2026-10-26 19:59:53 +01:00
Juan Manuel Bardallo 763a49f9cc feat(plugins): preserve other partition badges on partition status NONE (1.20.0)
oggui-debian-package/pipeline/head This commit looks good Details
oggui-debian-package/pipeline/tag This commit looks good Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2026-10-26 18:51:53 +01:00
Juan Manuel Bardallo 0cba5933e4 feat(plugins): handle granular disk and partition invalidation in Mercure listener 2026-10-26 13:56:51 +01:00
Juan Manuel Bardallo fb437fd8a9 feat(plugins): add generic partition-level status and badge support 2026-10-26 13:46:08 +01:00
Juan Manuel Bardallo 80e8cf95e6 fix(plugins): preserve disabled state, keep sidebar accessible, load inactive translations 2026-10-26 13:32:23 +01:00
Juan Manuel Bardallo 2aea3beab8 feat: soporte para activar y desactivar plugins en caliente (1.19.0)
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/tag This commit looks good Details
oggui-debian-package/pipeline/head This commit looks good Details
2026-10-23 14:41:14 +02:00
Juan Manuel Bardallo f10613af82 feat(plugins): decouple plugin Mercure status and implement dynamic i18n support
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
oggui-debian-package/pipeline/tag This commit looks good Details
2026-10-23 09:48:31 +02:00
Juan Manuel Bardallo 9f34d2c234 revert(task-logs): restrict cancellable traces to pending and in-progress
oggui-debian-package/pipeline/head This commit looks good Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/tag This commit looks good Details
2026-10-22 13:54:39 +02:00
Juan Manuel Bardallo 6994ab580b fix(task-logs): allow selecting and cancelling SENT traces
oggui-debian-package/pipeline/tag This commit looks good Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
2026-10-22 13:29:27 +02:00
Juan Manuel Bardallo bc74caad42 feat(commands): pass target_os and enable smart queuing for plugin commands
oggui-debian-package/pipeline/tag This commit looks good Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
2026-10-22 12:59:31 +02:00
Juan Manuel Bardallo 27a9cc3f7f feat(plugins): implement declarative field.action contract standard and decouple script generation
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
oggui-debian-package/pipeline/tag This commit looks good Details
2026-10-22 12:29:20 +02:00
Juan Manuel Bardallo 063762e1e4 fix(commands): dispatch real HTTP plugin commands with offline queue modal and OS validation
oggui-debian-package/pipeline/head This commit looks good Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/tag This commit looks good Details
2026-10-22 11:30:02 +02:00
Juan Manuel Bardallo a5c4b7b43a feat(plugins): add syntax highlighting for script viewer and fix blank exclusions handling
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
oggui-debian-package/pipeline/tag This commit looks good Details
2026-10-22 11:23:33 +02:00
Juan Manuel Bardallo 67572c313c docs(changelog): document plugin reload synchronization fix for 1.16.0
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
oggui-debian-package/pipeline/tag This commit looks good Details
2026-10-22 11:18:24 +02:00
Juan Manuel Bardallo 49ccdfc4ec fix(plugins): ensure reactive data binding and loading state on page reload 2026-10-22 11:17:48 +02:00
Juan Manuel Bardallo c4aa997516 fix(ui): improve script code contrast and relocate action buttons to header
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
oggui-debian-package/pipeline/tag This commit looks good Details
2026-10-22 11:09:51 +02:00
Juan Manuel Bardallo e5a83a05cd feat(plugins): enhance plugin command grouping, OS compatibility check, and script generation
oggui-debian-package/pipeline/head This commit looks good Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/tag This commit looks good Details
2026-10-22 10:12:42 +02:00
Juan Manuel Bardallo 056c89ea53 feat(plugins): implement dynamic plugin architecture and bump version to 1.16.0 2026-10-22 09:41:51 +02:00
Juan Manuel Bardallo 5576757c7b feat(commands): support offline client execution with auto-queueing and modernize components
oggui-debian-package/pipeline/head This commit looks good Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/tag This commit looks good Details
2026-10-20 10:15:08 +02:00
Juan Manuel Bardallo 414076b45f fix(ui): ensure numerical ID resolution in GET filters for OU selections
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
oggui-debian-package/pipeline/tag This commit looks good Details
2026-10-09 15:03:41 +02:00
Juan Manuel Bardallo a57fa1fcfa fix(ui): ensure robust IRI extraction for organizationalUnit payloads
oggui-debian-package/pipeline/tag This commit looks good Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
2026-10-09 10:58:17 +02:00
Juan Manuel Bardallo 12bf0eb743 chore: bump version to 1.14.2
oggui-debian-package/pipeline/head This commit looks good Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/tag This commit looks good Details
2026-10-06 14:39:05 +02:00
Juan Manuel Bardallo 91429f7b40 fix(ui): ensure ou-select globally uses IRIs and harden relational payloads
oggui-debian-package/pipeline/head This commit looks good Details
oggui-debian-package/pipeline/tag This commit looks good Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2026-10-06 14:38:29 +02:00
Juan Manuel Bardallo 0333a3a894 fix(dhcp): resolve undefined organizational unit id when adding clients to subnet
oggui-debian-package/pipeline/tag This commit looks good Details
oggui-debian-package/pipeline/head This commit looks good Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2026-10-06 14:22:05 +02:00
Juan Manuel Bardallo 609304acc0 feat: standardise and refactor OU selection and migrate control flow
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
oggui-debian-package/pipeline/tag This commit looks good Details
2026-10-05 09:26:55 +02:00
Juan Manuel Bardallo bdfe4ac0df feat(remotepc): add UI toggle for reboot on unreserve and fix CSS syntax error
oggui-debian-package/pipeline/head This commit looks good Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/tag This commit looks good Details
2026-10-02 13:57:56 +02:00
Juan Manuel Bardallo 25d9f138b4 docs(changelog): fix formatting and missing special characters in 1.12.0 notes
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
2026-09-30 15:09:58 +02:00
Juan Manuel Bardallo e6c387b972 feat(ui): persistance for tree panel and refinement of RemotePC badge
oggui-debian-package/pipeline/tag This commit looks good Details
oggui-debian-package/pipeline/head This commit looks good Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2026-09-30 14:21:38 +02:00
Juan Manuel Bardallo bd8ca8d36a feat(ui): add image cloning column with multiple image support
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
2026-09-24 14:09:37 +02:00
Juan Manuel Bardallo 809b368e4a chore: release 1.11.1 with ui improvements and pxe autoselect
oggui-debian-package/pipeline/head This commit looks good Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/tag This commit looks good Details
2026-09-24 13:53:14 +02:00
Juan Manuel Bardallo 952482e68b fix(deploy-image): add missing html template and translations
oggui-debian-package/pipeline/head This commit looks good Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/tag This commit looks good Details
2026-09-24 09:29:45 +02:00
Juan Manuel Bardallo 258a940170 docs: release version 1.11.0
oggui-debian-package/pipeline/head This commit looks good Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2026-09-23 14:12:47 +02:00
Juan Manuel Bardallo c5010fca0a fix: namespace localStorage keys with username
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
2026-09-23 14:00:45 +02:00
Juan Manuel Bardallo 744e721598 feat: persist deploy post-actions in localStorage
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
2026-09-23 13:57:04 +02:00
Juan Manuel Bardallo d7d2f9c7be Fix: remover IP del nombre del repositorio en selector de despliegue
oggui-debian-package/pipeline/head This commit looks good Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/tag This commit looks good Details
2026-09-22 14:21:12 +02:00
Juan Manuel Bardallo 11e70ea7cd docs: update CHANGELOG.md for version 1.10.1
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
oggui-debian-package/pipeline/tag This commit looks good Details
2026-09-18 10:28:00 +02:00
Juan Manuel Bardallo da724005f7 fix(menu): pass baseUrl as query param for Vista Global GET request 2026-09-17 15:03:05 +02:00
Juan Manuel Bardallo 0860b3866b fix(menu): pass baseUrl to backend for accurate base tag injection 2026-09-17 14:55:24 +02:00
Juan Manuel Bardallo 205af45b72 fix(menu): remove manual base tag injection since PHP handles it 2026-09-17 14:48:28 +02:00
Juan Manuel Bardallo 4c2867b1c4 fix(menu): request base tag injection via gui param and fix preview base href 2026-09-17 14:36:43 +02:00
Juan Manuel Bardallo 4224af359b feat: ou-operator permissions (createImage, prevent Centro)
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
oggui-debian-package/pipeline/tag This commit looks good Details
2026-09-17 13:27:33 +02:00
Juan Manuel Bardallo 514c7ad1bb docs: add release notes for version 1.9.0
oggui-debian-package/pipeline/tag This commit looks good Details
2026-09-17 12:30:36 +02:00
Juan Manuel Bardallo 361b98d221 feat: UI search filters, network speeds and createImage for data partitions 2026-09-17 12:21:56 +02:00
Juan Manuel Bardallo 7d71ca92f3 Merge branch 'main' into devel
testing/ogGui-multibranch/pipeline/pr-main Build queued... Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
oggui-debian-package/pipeline/tag This commit looks good Details
2026-09-01 09:45:55 +02:00
Juan Manuel Bardallo e0736eaf99 fix(inventory): hide CACHE and EMPTY partitions and disable non-OS partitions in software inventory modal
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
testing/ogGui-multibranch/pipeline/pr-main Build started... Details
2026-07-31 14:10:25 +02:00
Juan Manuel Bardallo 7a16e3a128 fix(inventory): declare partitions property in boot-so-partition component
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
2026-07-31 14:08:46 +02:00
Juan Manuel Bardallo 97fb0fce6d fix(inventory): use disk partition selector in software inventory and hide CACHE partition in boot OS view
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head There was a failure building this commit Details
2026-07-31 14:08:16 +02:00
Juan Manuel Bardallo 955a29104f fix(inventory): preserve partition 0 in boot OS view to display disk type
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head There was a failure building this commit Details
2026-07-31 14:05:08 +02:00
Juan Manuel Bardallo 5ece8cfec9 fix(inventory): unify badge colors and fix disk type badge missing in deploy and create image views
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
2026-07-31 14:03:58 +02:00
Juan Manuel Bardallo 4154e18ab6 fix(inventory): add disk type badge to client details and partition selector views
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
2026-07-31 13:58:02 +02:00
Juan Manuel Bardallo 41b0974608 chore(release): bump version and update changelog for 1.8.3
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
2026-07-31 13:47:05 +02:00
Juan Manuel Bardallo 70f71ef99b refactor(inventory): use new Angular control flow syntax for diskType
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
2026-07-31 13:42:36 +02:00
Juan Manuel Bardallo 47d7506877 feat(inventory): display network speed and disk type badges
oggui-debian-package/pipeline/head This commit looks good Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2026-07-31 13:38:41 +02:00
Manuel Aranda Rosales 86b424fe89 Merge pull request 'New minor version. Several changes. New release.' (#97) from devel into main
oggui-debian-package/pipeline/head This commit looks good Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/tag This commit looks good Details
Reviewed-on: #97
2026-07-30 13:33:51 +02:00
Manuel Aranda Rosales 4c3e6556d6 Merge branch 'devel' of ssh://ognproject.evlt.uma.es:21987/opengnsys/oggui into devel
testing/ogGui-multibranch/pipeline/pr-main Build queued... Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
2026-07-30 13:33:03 +02:00
Manuel Aranda Rosales a13335df69 Merge branch 'main' into devel 2026-07-30 13:32:49 +02:00
Juan Manuel Bardallo 824c504573 Fix(image): filter images by client repository on update
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
2026-07-30 13:11:44 +02:00
Juan Manuel Bardallo 0e375f5b39 Feat(traces): consume output and finishedAt from Mercure events for real-time log updates
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
2026-07-30 11:40:23 +02:00
Manuel Aranda Rosales 54f4d5aebc Merge branch 'devel' of ssh://ognproject.evlt.uma.es:21987/opengnsys/oggui into devel
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
2026-07-29 00:04:30 +02:00
Manuel Aranda Rosales 2b77eb8afa Corrected ou-operator bug 2026-07-29 00:02:36 +02:00
Juan Manuel Bardallo 4a9fc809db Docs: update CHANGELOG for git repository deletion UI fix
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
2026-07-28 12:52:49 +02:00
Juan Manuel Bardallo b3a59b659f Fix(oggui): keep repository list modal open after deletion
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
2026-07-28 12:50:57 +02:00
Juan Manuel Bardallo ebc09f8ca5 Refactor client action menu to use shared matMenuContent
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
2026-07-27 18:36:08 +02:00
Juan Manuel Bardallo 348df41425 Docs(CHANGELOG): document OutputDialog text formatting fix
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
2026-07-22 13:13:19 +02:00
Juan Manuel Bardallo 906cab9143 Refactor(OutputDialog): use modern Angular @if control flow
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
2026-07-22 12:13:31 +02:00
Juan Manuel Bardallo 6525bb08cc Fix(OutputDialog): properly render string outputs without JSON stringification
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
2026-07-22 12:10:46 +02:00
Manuel Aranda Rosales 087f462d4d Merge branch 'devel' of ssh://ognproject.evlt.uma.es:21987/opengnsys/oggui into devel
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
2026-07-19 17:36:17 +02:00
Manuel Aranda Rosales 3463a59071 ref 3018. Cancel multiple taskj 2026-07-19 17:36:09 +02:00
Juan Manuel Bardallo 03c8600984 docs: actualizar CHANGELOG para la version 1.8.1
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
2026-07-17 11:10:42 +02:00
Manuel Aranda Rosales 0adae7dbae ref 3009. Added excecute task right now
oggui-debian-package/pipeline/head This commit looks good Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2026-07-15 11:17:34 +02:00
Manuel Aranda Rosales c72d49f576 Merge branch 'devel' of ssh://ognproject.evlt.uma.es:21987/opengnsys/oggui into devel
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
2026-07-14 10:45:17 +02:00
Manuel Aranda Rosales d739c6b81c ref 3008. Last selected OU 2026-07-14 10:45:01 +02:00
Manuel Aranda Rosales 379886134d ref 2979. Deploy generate instructions 2026-07-14 10:44:18 +02:00
Juan Manuel Bardallo a351883385 Refactor boot-so-partition modal to use shared app-disk-partition-selector component
oggui-debian-package/pipeline/head This commit looks good Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2026-07-14 09:41:06 +02:00
Manuel Aranda Rosales 9797b14038 ref 3007. Software profile in versioned image 2026-07-13 10:42:33 +02:00
Manuel Aranda Rosales 24517a8649 ref 2994. Hardware profile updated logic in childrens 2026-07-13 10:41:52 +02:00
Manuel Aranda Rosales aa52c89c52 ref 3007. Fixed create software profile bug 2026-07-13 10:39:53 +02:00
Juan Manuel Bardallo dd1b20b9d1 feat(oggui): Add OG Repository selector to deploy image menu
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
- Added OG Repository dropdown for both monolithic and git image flows.

- Fixed CSS layout issues for the select dropdowns.

- Updated translation keys to differentiate between OG Repository and Git Repository.

- Prevented loadImages from making a 404 API request when using Git flow.
2026-07-10 09:47:38 +02:00
Juan Manuel Bardallo 3390500f06 fix(deploy-image): show instructions in cache mode and refactor to @if/@for syntax
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
2026-07-09 13:51:10 +02:00
Manuel Aranda Rosales 97965a346f Merge branch 'devel' of ssh://ognproject.evlt.uma.es:21987/opengnsys/oggui into devel
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
2026-07-09 10:14:43 +02:00
Manuel Aranda Rosales 06dbcabf50 ref 2995. Updated roles 2026-07-09 10:14:26 +02:00
Juan Manuel Bardallo 003c940b34 refactor: migrate *ngFor to @for in add-user-modal template
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
2026-07-09 09:15:14 +02:00
Juan Manuel Bardallo 6beeee4f0e fix: remove API call from switchLanguage to avoid destructive PUT 2026-07-08 14:19:07 +02:00
Juan Manuel Bardallo ee05f6a2cd fix: use PUT instead of PATCH for language preference update 2026-07-08 14:13:31 +02:00
Juan Manuel Bardallo 5bc7527ce7 feat(user): add language preference to user profile and header
oggui-debian-package/pipeline/head This commit looks good Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
- Add language field to JwtPayload interface and getter in AuthService
- Header applies JWT language on init (server takes priority over localStorage)
- Switching language from header PATCHes preference to API immediately
- Add language selector to add/edit user modal (admin)
- Add userLanguage and userLanguageHint i18n keys to es/en/cat locales
2026-07-08 13:54:44 +02:00
Manuel Aranda Rosales 2a225b9539 ref 2979. Generar instrucciones de deploy imagen actualizado. 2026-07-07 17:23:48 +02:00
Manuel Aranda Rosales fe75009481 ref 2977. Actualizar formato horario 2026-07-07 17:22:58 +02:00
Manuel Aranda Rosales 5755b79bc7 ref 2994. Cambios respecto al hardware profile 2026-07-07 17:21:53 +02:00
Nicolas Arenas 71f0268eda Merge pull request 'docs(oggui): actualizar CHANGELOG para versión 1.8.0' (#96) from devel into main
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
Reviewed-on: #96
2026-06-16 18:01:06 +02:00
Nicolas Arenas d856439444 Merge pull request 'feat(oggui): sistema de permisos por rol + mejoras de UX y tests' (#95) from devel into main
oggui-debian-package/pipeline/head This commit looks good Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
Reviewed-on: #95
2026-06-16 17:59:54 +02:00
Nicolas Arenas ede9161168 docs(oggui): actualizar CHANGELOG para versión 1.8.0
testing/ogGui-multibranch/pipeline/pr-main Build queued... Details
oggui-debian-package/pipeline/head This commit looks good Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 17:58:09 +02:00
Nicolas Arenas 221131718f feat(oggui): añadir infraestructura E2E con Playwright
oggui-debian-package/pipeline/head There was a failure building this commit Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
testing/ogGui-multibranch/pipeline/pr-main Build started... Details
- playwright.config.ts: configuración con globalSetup/Teardown,
  webServer (reuseExistingServer), SSL bypass para cert autofirmado
- e2e/helpers/api.ts: crea/borra usuarios de test vía API de ogCore,
  persiste IRIs en .e2e-state.json para evitar problemas entre procesos
- e2e/helpers/auth.ts: loginAs(), navigateTo(), expectSidebarLink()
- e2e/permissions/: 4 specs (super-admin, ou-admin, ou-operator, ou-minimal)
  que verifican sidebar, rutas y botones de acción por rol
- package.json: scripts e2e, e2e:headed, e2e:ui, e2e:report
  y dependencia @playwright/test
- .gitignore: añade .e2e-state.json y playwright-report/
- elimina test-results/ogGui-junit-report.xml del tracking de git
2026-06-16 10:47:03 +02:00
Nicolas Arenas 583e846b36 test(oggui): añadir tests unitarios de permisos por rol
- auth.service.spec.ts: tests para isSuperAdmin, isOUAdmin, isOUOperator,
  isOUMinimal, userCategory y getCurrentRole con tokens JWT sintéticos
- role.guard.spec.ts: matriz completa 26 rutas × 4 roles, verifica
  redirección a /groups cuando el acceso está bloqueado
- sidebar.component.spec.ts: verifica ítems visibles por rol
  (ou-operator/minimal ven solo /groups; admins ven los 9 ítems)
- ogboot-status.component.spec.ts: corrige import incorrecto
  OgbootStatusComponent → GlobalOgbootStatusComponent
2026-06-16 10:46:36 +02:00
Nicolas Arenas 8ce68cbaa3 Merge branch 'devel' of ssh://ognproject.evlt.uma.es:21987/opengnsys/oggui into devel 2026-06-16 10:41:13 +02:00
Juan Manuel Bardallo 15265b7a67 Merge branch 'devel' of https://ognproject.evlt.uma.es/gitea/opengnsys/oggui into devel
oggui-debian-package/pipeline/head There was a failure building this commit Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2026-06-15 09:01:00 +02:00
Juan Manuel Bardallo 3bbca40dbb Modificado comportamiento al hacer click en un aula, la petición de mostrar ordenadores y la de actualizar el estado de cada uno, se hacen secuencialmente y no en paralelo, asi evitamos el loader general mientras se resuelven los estados de los equipos 2026-06-15 08:56:51 +02:00
Juan Manuel Bardallo a26a1727d7 Modificado comportamiento al hacer click en un aula, la petición de mostrar ordenadores y la de actualizar el estado de cada uno, se hacen secuencialmente y no en paralelo, asi evitamos el loader general mientras se resuelven los estados de los equipos
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
2026-06-12 10:28:34 +02:00
Juan Manuel Bardallo bc3190380e Añadido botón de cerrar en la ventana de plano de aula
oggui-debian-package/pipeline/head This commit looks good Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2026-06-12 09:03:24 +02:00
Nicolas Arenas 7fca193c2a fix(oggui): ocultar botones de creacion de OU y cliente para ou-operator
Los botones 'Nueva Unidad Organizativa' y 'Nuevo Cliente' (cabecera y
menu contextual del arbol) quedan ocultos para ou-operator, alineando
la UI con la restriccion de backend en ogcore.
2026-05-27 12:03:57 +02:00
Nicolas Arenas 1e1319d10c fix(oggui): permitir acceso de ou-operator a despliegue, particion e imagen
Las rutas clients/deploy-image, clients/partition-assistant,
clients/run-script y clients/:id/create-image ahora incluyen
'ou-operator' en allowedRoles.

El execute-command ya tenia deploy-image y partition en la lista de
comandos permitidos para ou-operator; solo faltaba el acceso a la ruta.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 11:44:58 +02:00
Nicolas Arenas 9872b9c7e4 fix(oggui): ocultar botones de escritura de infraestructura para ou-admin
Los botones de crear, editar y eliminar en servidores DHCP, Boot,
Comandos y Repositorios quedan visibles unicamente para super-admin,
alineando la UI con las restricciones de backend anadidas en ogcore.
2026-05-26 15:21:18 +02:00
Nicolas Arenas 2b08510e12 Merge pull request 'feat(oggui): visor de logs de disco en tiempo real para componentes de estado global' (#94) from devel into main
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
Reviewed-on: #94
2026-05-18 15:50:41 +02:00
Juan Manuel Bardallo 9addf93660 ficheros de idiomas ordenados alfabeticamente, fichero catalan revisado
testing/ogGui-multibranch/pipeline/pr-main There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
2026-05-18 15:00:39 +02:00
Juan Manuel Bardallo 81d58bc54f Merge pull request 'import-legacy' (#93) from import-legacy into devel
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
testing/ogGui-multibranch/pipeline/pr-main There was a failure building this commit Details
Reviewed-on: #93
2026-05-18 14:20:33 +02:00
Juan Manuel Bardallo cb1e6a0fa9 Resuelve conflicto de fusión en CHANGELOG.md
testing/ogGui-multibranch/pipeline/head Build started... Details
testing/ogGui-multibranch/pipeline/pr-devel Build queued... Details
2026-05-18 14:20:04 +02:00
Nicolas Arenas 5dd7b542f7 update CHANGELO.md
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
2026-05-14 21:47:01 +02:00
Nicolas Arenas 647fa5079e feat(oggui): soporte de múltiples SYSLOG_IDENTIFIER en visor de logs de disco
oggui-debian-package/pipeline/head This commit looks good Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
- StatusLogsComponent: @Input identifiers[] en lugar de identifier string,
  parseo de entries JSON estructurado con formato [timestamp] ID: mensaje
- Templates ogboot, ogdhcp, ogrepository, ogcore-status: identifiers correctos
  por componente y host basado en IP en lugar de nombre del servidor
- Añadidos métodos getSelectedXxxIp() en ogboot, ogdhcp y ogrepository status
2026-05-14 21:38:35 +02:00
Nicolas Arenas 59067ee94c Update Jenkinsfile
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
2026-05-14 17:53:16 +02:00
Nicolas Arenas 0eda6ea4d3 Update Jenkinsfile
oggui-debian-package/pipeline/head There was a failure building this commit Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2026-05-14 17:50:10 +02:00
Juan Manuel Bardallo 1d8e5b38c6 modificada recepcion de mensajes de mercure durante la importación
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2026-05-14 14:40:02 +02:00
Juan Manuel Bardallo e9a193ecde Merge branch 'main' of https://ognproject.evlt.uma.es/gitea/opengnsys/oggui into disk-logs
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
2026-05-14 13:19:58 +02:00
Juan Manuel Bardallo 6a3c75ccb7 Creada vista de logs de disco de cada componente de OG en la vista estado global, llama al endpoint de ogcore /opengnsys/system/disk-logs 2026-05-14 13:19:52 +02:00
Juan Manuel Bardallo d6349dd3f4 Merge pull request 'Añadida opción en Administración para importar una base de datos OG 1.1 en la nueva version. Permite subir fichero .sql o .zip o usar parametros de conexión a la base de datos de OG 1.1' (#92) from import-legacy into main
oggui-debian-package/pipeline/head This commit looks good Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
Reviewed-on: #92
2026-05-08 15:39:37 +02:00
Juan Manuel Bardallo b0d1f743a8 Añadida opción en Administración para importar una base de datos OG 1.1 en la nueva version. Permite subir fichero .sql o .zip o usar parametros de conexión a la base de datos de OG 1.1
testing/ogGui-multibranch/pipeline/head Build started... Details
testing/ogGui-multibranch/pipeline/pr-main Build queued... Details
2026-05-08 15:38:47 +02:00
Nicolas Arenas 2568efbfa3 Merge pull request 'feat(oggui): ask to queue reboot/poweroff if client is busy' (#91) from encolar_poweroff_reboot into main
oggui-debian-package/pipeline/head This commit looks good Details
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
Reviewed-on: #91
2026-05-08 07:19:13 +02:00
Nicolas Arenas df3ff55fb8 feat(oggui): ask to queue reboot/poweroff if client is busy
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
testing/ogGui-multibranch/pipeline/pr-main Build started... Details
Show a confirmation modal before sending reboot and poweroff requests,
allowing the user to choose whether to queue the command when the client
is busy (HTTP 409). Passes the 'queue' flag to the API accordingly.
2026-05-08 07:14:06 +02:00
Manuel Aranda Rosales dcfb7c7d07 Merge pull request 'Fixed partition assistant' (#90) from develop into main
testing/ogGui-multibranch/pipeline/head There was a failure building this commit Details
oggui-debian-package/pipeline/head This commit looks good Details
Reviewed-on: #90
2026-05-07 22:28:24 +02:00
152 changed files with 10225 additions and 2138 deletions

View File

@ -1,5 +1,377 @@
# Changelog
## [1.26.0] - 2026-11-04
### Added
- **Restricción Declarativa de Comandos de Plugins según Estado de Cliente y SO Activo (`PluginService`, `PluginCommand`)**:
- Incorporadas las propiedades `activeOsOnly`, `excludeClientStatus` y `requireClientStatus` en la interfaz `PluginCommand`.
- La directiva `activeOsOnly: true` exige estrictamente que el equipo esté arrancado y en ejecución en uno de los sistemas operativos declarados en `os` (ej. Windows o Linux), impidiendo que se habiliten comandos de escritorio cuando el cliente se encuentra en entorno de mantenimiento `ogLive` o apagado aunque tenga una partición con dicho SO instalada en disco.
- Soportada exclusión e inclusión explícita por estado del cliente (`excludeClientStatus: ["og-live"]`, etc.) en `isCommandSupportedForClients`.
- En `ExecuteCommandComponent`, evaluado el estado unitario (`clientState`) para garantizar que la disponibilidad de comandos en el menú contextual de cada tarjeta individual responda fielmente al estado en tiempo real.
## [1.25.1] - 2026-11-01
### Refactored
- **Modularización y Optimización de Arquitectura en `PluginService`**:
- Descompuesto el monolito de ~900 líneas en submódulos especializados bajo `src/app/services/plugin/`:
- `plugin.types.ts`: Modelos, interfaces tipadas y estructuras de datos (`PluginCommand`, `RoleDefinition`, `PluginBadgeRule`, `PluginManifest`, etc.).
- `plugin-state.service.ts`: Manejo reactivo del estado dinámico de plugins en memoria, caché de metadatos contextuales y suscripción a eventos en tiempo real vía Mercure (Server-Sent Events).
- `plugin-roles.service.ts`: Comprobación de roles y permisos granulares de usuarios y comandos, jerarquía de acceso y sincronización con `ogCore`.
- `plugin-badge.service.ts`: Resolución desacoplada de badges visuales e internacionalización de tooltips dinámicos con metadatos.
- Eliminada la duplicación completa (~100 líneas) entre `getClientBadges()` y `getPartitionBadges()`, unificando la lógica de interpolación y renderizado en una función auxiliar común (`buildBadge`).
- `PluginService` actúa ahora como una fachada limpia, liviana y 100% retrocompatible (~280 líneas), preservando sin alteraciones todas las firmas públicas y re-exportando los tipos para los componentes consumidores.
## [1.25.0] - 2026-11-01
### Added
- **Restricción Declarativa de Comandos de Plugins según Estado (`PluginService`, `ExecuteCommandComponent`)**:
- Incorporadas las propiedades opcionales `requireStatus` y `excludeStatus` en la interfaz `PluginCommand`, permitiendo a los plugins condicionar la disponibilidad de sus acciones en función del estado actual de los clientes.
- Implementado filtrado reactivo en `isCommandSupportedForClients`: los comandos solo se habilitan si el estado del equipo coincide con los estados requeridos (`requireStatus`) y no figura entre los estados excluidos (`excludeStatus`).
- Si un puesto de acceso libre (Aquarium) se encuentra desactivado (`DISABLED`), las opciones de reserva, liberación y renovación quedan deshabilitadas, permitiendo únicamente la acción de activación.
- Adaptación dinámica de etiqueta en comandos de conmutación de estado (`ogaquarium-toggle-status`): muestra "Activar puesto" o "Desactivar puesto" según el estado actual cuando se selecciona un único equipo.
- Suscripción reactiva a `statusUpdates$` en `ExecuteCommandComponent` para recalcular al instante la disponibilidad de acciones al recibir eventos Mercure en tiempo real.
## [1.24.7] - 2026-10-31
### Refactored
- **Gestión Nativa por Disco y Partición con Contrato Estandarizado en `PluginService`**:
- Eliminada la abstracción innecesaria de `subtarget`, operando directamente con las coordenadas nativas de OpenGnSys (`disk_number` y `partition_number`).
- La interfaz de usuario opera de manera 100% pasiva y desacoplada: es el plugin emisor (backend / listener Mercure / endpoint `/status`) quien suministra explícitamente tanto el estado global del cliente (`client_status` / `clientStatus`) como el estado de cada partición (`status` junto a `disk_number` y `partition_number`).
- Métodos nativos y directos en `PluginService`: `setPartitionPluginStatus`, `getPartitionPluginStatus`, `clearPartitionPluginStatus` y `getPartitionBadges(client, diskNumber, partitionNumber)`.
- Eliminadas las iteraciones o inferencias en `getClientBadges`: el badge general del cliente se consulta directamente en memoria sin realizar bucles de búsqueda sobre particiones ni calcular prioridades arbitrarias.
- Soporte completo de metadatos contextuales en tooltips tanto a nivel general del equipo como a nivel de cada partición individual.
## [1.24.6] - 2026-10-31
### Fixed
- **Desacoplamiento Estricto y Sincronización Agnóstica en `PluginService`**:
- Eliminadas todas las cadenas hardcodeadas de estados específicos de plugins (`['REMOVING', 'CHECKING', 'FREEZING', 'UNFREEZING']`, `'FREE'`, `'UNFROZEN'`).
- Implementada gestión genérica del ciclo de vida de particiones (`handlePartitionClear` y `syncGlobalStatusFromPartitions`): al recibir un estado `NONE` para una partición, se comprueba dinámicamente si al cliente le quedan otras particiones con estado activo; si no queda ninguna, se purga de forma inmediata el estado global en memoria evitando badges fantasma o residuales.
- La resolución de badges a nivel de equipo comprueba directamente contra el catálogo declarado por el plugin en su `manifest.json` (`plugin.badges`), eliminando filtros ad-hoc.
- El guardado y borrado de metadatos se gestiona de forma agnóstica según la presencia efectiva de datos contextuales en el payload.
## [1.24.5] - 2026-10-30
### Added
- **Soporte Genérico de Tooltips Dinámicos (`PluginBadgeRule.tooltipFields`)**:
- Incorporada la especificación declarativa `tooltipFields` en `PluginBadgeRule`, permitiendo a cualquier plugin declarar en su propio manifiesto qué campos de metadatos deben anexarse al tooltip (con `prefix` y `suffix` opcionales).
### Fixed
- **Actualización Reactiva en Tiempo Real y Auto-Conexión Mercure (`PluginService`, `groups.component`)**:
- La recepción de eventos SSE de Mercure en `PluginService` se ejecuta dentro de `NgZone.run()`, emitiendo a través del flujo observable reactivo `statusUpdates$`.
- Suscripción reactiva en `GroupsComponent` a `statusUpdates$`, activando la detección de cambios (`cd.markForCheck(); cd.detectChanges()`) de manera inmediata sin recargar ni salir de la pantalla.
- En `groups.component.html` y `client-details.component.html`, la expresión de seguimiento del control de flujo (`@for`) incorpora el tooltip (`track ... + (badge.tooltip || '')`), forzando la actualización instantánea de `[matTooltip]` en el DOM al cambiar los minutos restantes o datos.
- Auto-conexión garantizada de Mercure en `PluginService`: si la URL de Mercure se resuelve de forma asíncrona tras la carga de la configuración, se conecta de manera transparente al solicitar estados o badges.
### Removed
- **Desacoplamiento Total de Plugins en `PluginService`**:
- Eliminado todo código ad-hoc y variables específicas de dominio (`dni`, `remaining_minutes`, `student`, `user`, etc.) del motor genérico de `PluginService`. `oggui` pasa a ser 100% agnóstico e independiente de los plugins instalados.
- Retirado el temporizador `interval(30000)` en `GroupsComponent`. La arquitectura se mantiene 100% reactiva exclusivamente mediante los eventos push de Mercure (SSE).
## [1.24.1] - 2026-10-30
### Fixed
- **Detección Dinámica de Lenguaje y Resaltado en Editor de Scripts de Plugins (`PluginSettingsComponent`, `PluginService`)**:
- Corregido el badge de cabecera del editor de código en `plugin-settings` que mostraba estáticamente `POWERSHELL` incluso en scripts de Bash/Linux (`#!/bin/bash`).
- Incorporada la propiedad declarativa `language?: string` en `SchemaField` (`PluginService`), permitiendo a los plugins especificar directamente su sintaxis (ej. `bash`, `powershell`, `sh`).
- Detección automática inteligente como respaldo según el atributo `field.language`, el nombre de la clave (`linux`, `bash`, `ps1`, etc.) o el shebang del script (`#!/bin/bash`, `#!/bin/sh`).
- Adaptación contextual del motor de coloreado de sintaxis para comandos (`echo`, `mount`, `losetup`, `update-initramfs`, etc.), palabras clave (`if`, `fi`, `then`, `do`, `done`, etc.) y banderas de Linux (`--bind`, `-o`, etc.).
## [1.24.0] - 2026-10-28
### Added
- **Control de Acceso y Visibilidad de Funciones de Plugins por Rol (`PluginService`, `PluginSettingsComponent`, `ExecuteCommandComponent`)**:
- Incorporada especificación declarativa de roles autorizados (`roles?: string[]`) en la interfaz `PluginCommand`.
- Creada matriz interactiva de permisos en `PluginSettingsComponent` dentro de la nueva subpestaña *"Funciones y Roles"*, permitiendo a los administradores activar o revocar gráficamente el acceso a cada comando expuesto por cualquier plugin para cada rol del sistema.
- Los roles disponibles se extraen dinámicamente desde `ogcore` (`GET /user-groups`), con respaldo seguro en los roles canónicos de OpenGnSys (`ROLE_SUPER_ADMIN`, `ROLE_ORGANIZATIONAL_UNIT_ADMIN`, `ROLE_ORGANIZATIONAL_UNIT_OPERATOR`, `ROLE_ORGANIZATIONAL_UNIT_MINIMAL`).
- La personalización se persiste en la base de datos dentro de `configData.command_roles` de forma 100% desacoplada sin requerir alteraciones en el esquema backend.
- En `PluginService.getPluginGroups()`, los comandos disponibles en los menús de acción (`ExecuteCommandComponent`) se filtran reactivamente en función del rol y permisos del usuario autenticado, ocultando totalmente aquellas opciones que no le correspondan (ej. un conserje con perfil básico solo visualiza Reservar, Liberar y Renovar puesto, permaneciendo invisibles las funciones de instalación o mantenimiento).
- Total adhesión a la sintaxis moderna de control de flujo Angular (`@if`, `@for`) y diseño modular de estilos CSS sin atributos inline.
- Soporte de internacionalización integral (`@ngx-translate`) con catálogos actualizados en español (`es.json`), inglés (`en.json`) y catalán (`cat.json`) para todos los textos, pestañas, cabeceras y notificaciones toast de la configuración de roles.
## [1.23.0] - 2026-10-27
### Added
- **Enriquecimiento Contextual de Tooltips en Badges de Plugins (`PluginService`, `groups.component`)**:
- Incorporada caché reactiva de metadatos de clientes (`clientPluginMetadataMap`) en `PluginService`, extrayendo automáticamente atributos contextuales proporcionados por cualquier plugin tanto en la consulta inicial (`statusEndpoint`) como en las actualizaciones en tiempo real recibidas por Mercure.
- Al renderizar los badges (`getClientBadges`), si existen atributos contextuales como el identificador del alumno/usuario (`dni`, `user`) y el tiempo restante (`remaining_minutes`, `remainingMinutes`), se añaden dinámicamente al texto del tooltip (ej. `"Puesto reservado y en uso (DNI: 44212977P · 58 min restantes)"`), preservando el badge visualmente limpio y compacto (`Ocupado`, `A punto de expirar`).
- Limpieza automática y atómica de metadatos cuando el puesto queda libre (`FREE`) o se reinicia (`NONE`), manteniendo sincronizada la consola web en todo momento sin acoplamiento a ningún plugin en concreto.
## [1.22.0] - 2026-10-27
### Added
- **Diálogos Dinámicos de Parámetros y Confirmación en Comandos de Plugins (`PluginCommandModalComponent`, `ExecuteCommandComponent`)**:
- Incorporada especificación de parámetros interactivos (`params?: CommandParam[]`) y mensajes de confirmación (`confirm?: string; confirmKey?: string`) en la interfaz declarativa `PluginCommand`.
- Creado el componente modal genérico `PluginCommandModalComponent` que renderiza dinámicamente formularios reactivos con soporte para campos de texto, números, selectores y conmutadores (`checkbox`), respetando validaciones de obligatoriedad (`required`) e internacionalización con `@ngx-translate`.
- En `ExecuteCommandComponent`, al disparar un comando de cualquier plugin que requiera parámetros o confirmación, se abre automáticamente el modal interactivo, inyectando los valores resultantes en el cuerpo de la petición POST enviada al endpoint del plugin de forma 100% desacoplada.
- Total adhesión a la sintaxis moderna de control de flujo Angular (`@if`, `@for`, `@switch`) y separación estricta de estilos sin atributos inline.
## [1.21.1] - 2026-10-27
### Fixed
- **Desacoplamiento Total y Agnóstico en Plugins (`PluginService`, `PluginSettingsComponent`)**:
- Eliminado cualquier acoplamiento o referencia estática a nombres de plugins en la consola web (`PluginSettingsComponent`, `PluginService` y catálogos globales `locale/*.json`).
- El sistema opera de forma 100% agnóstica y dinámica: la presencia de la sub-pestaña "Historial y Registros" responde exclusivamente a la declaración de `recordsEndpoint` en el manifiesto de cualquier plugin.
- La internacionalización de plugins se carga dinámicamente en caliente mediante `@ngx-translate` consultando el endpoint `/i18n/{lang}` de cada plugin, respetando la estructura estándar de namespaces anidados como en `ogfreeze`.
## [1.21.0] - 2026-10-26
### Added
- **Sub-pestaña Genérica de Historial y Registros en Plugins (`PluginManifest`, `PluginSettingsComponent`)**:
- Incorporado soporte en `PluginManifest` para `recordsEndpoint` y `recordsColumns` (`RecordColumn`), permitiendo a cualquier plugin declarar un origen de datos tabular desacoplado.
- Añadido selector de sub-pestañas en `plugin-settings` para alternar entre "Configuración" e "Historial y Registros".
- Vista de registros completa con buscador en tiempo real (por DNI, puesto o aula), ordenación cronológica, paginación reactiva y contador de resultados.
- Soporte de exportación a archivo CSV con descarga directa desde backend (`${recordsEndpoint}/export`) y fallback automático de generación en cliente con BOM UTF-8 para compatibilidad universal.
- Renderizado adaptativo de celdas con soporte de badges semánticos para identificadores (DNI), duraciones y motivos de cierre de sesión (`TIMEOUT`, `RELEASED_BY_CONCIERGE`, `DISABLED_BY_ADMIN`).
- Total adhesión a la sintaxis moderna de control de flujo Angular (`@if`, `@for`, `@switch`) y estilos reutilizables sin atributos inline.
## [1.20.0] - 2026-10-26
### Added
- **Soporte de Estados y Badges a Nivel de Partición en Plugins (`PluginService`, `groups.component`)**:
- Incorporada gestión granular de estados por partición (`${plugin}:${clientId}:${disk}:${partition}`).
- En eventos Mercure con `status === 'NONE'` en una partición específica, se limpia la clave de la partición y cualquier estado transitorio global obsoleto (`REMOVING`, `CHECKING`, etc.), permitiendo que `getClientBadges` evalúe de inmediato y preserve el badge activo de cualquier otra partición protegida del equipo.
- Soporte de invalidación granular por disco y partición en eventos de imagen y particionado.
## [1.19.0] - 2026-10-23
### Added
- **Activación y Desactivación Dinámica de Plugins (`PluginService`, `plugins.component`, `plugin-settings.component`)**:
- Incorporada la capacidad de activar y desactivar plugins en caliente sin desinstalarlos del sistema.
- Al desactivar un plugin, sus comandos contextuales y badges se ocultan automáticamente de las tarjetas y menús de clientes en toda la consola web, preservando íntegra su configuración en base de datos.
- En la vista de administración `/plugins`, se integró un interruptor deslizante (`mat-slide-toggle`) en la cabecera de configuración para conmutar entre `Activo` e `Inactivo` con notificación toast y actualización reactiva.
- Añadido indicador visual de estado y conteo en la cabecera del panel (`X Activo(s) / Y Instalado(s)`) y badge `Inactivo` en las pestañas de los plugins deshabilitados.
## [1.18.0] - 2026-10-23
### Added
- **Desacoplamiento Total de Plugins y Eventos Mercure (`PluginService`, `groups.component`, `execute-command.component`)**:
- `PluginService`: Suscripción dinámica y agnóstica al topic genérico de Mercure `plugins`, procesando eventos de cualquier plugin de forma desacoplada y actualizando el estado de los clientes en tiempo real sin hardcoding.
- Consulta agnóstica de endpoints de estado (`statusEndpoint`) y reglas de badges (`badges`) declaradas dinámicamente en los manifiestos de los plugins.
- Inferencia dinámica de badges contextuales en clientes (`getClientBadges`) mapeados directamente desde la especificación `badges` del manifiesto del plugin.
- Limpieza de estilos en `groups.component.css`, adoptando clases semánticas universales (`.badge-info`, `.badge-neutral`, `.badge-pending`, `.badge-error`, `.badge-success`, `.badge-warning`).
- **Sistema Estandarizado de Internacionalización Dinámica de Plugins (`PluginService`, `plugins.component`, `plugin-settings.component`, `execute-command.component`)**:
- Incorporada la carga e inyección en caliente de traducciones (`i18n`) desde el endpoint `/api/plugins/{name}/i18n/{lang}` de cada plugin directamente en `@ngx-translate`, sin necesidad de alterar los ficheros de idioma en disco ni requerir recompilación.
- Soporte completo para claves semánticas (`titleKey`, `descriptionKey`, `labelKey`, `tooltipKey`) en comandos, badges y formularios de configuración de plugins, con reactividad automática ante el cambio de idioma en la cabecera.
## [1.17.2] - 2026-10-22
### Revert
- **Cancelación de Tareas (`task-logs.component`)**:
- Revertida la modificación temporal que permitía seleccionar y cancelar trazas en estado `Enviado` (`sent`). Se mantiene la regla del sistema que solo permite seleccionar y cancelar trazas en estado `pending` o `in-progress`.
## [1.17.0] - 2026-10-22
### Added
- **Encolado Inteligente por Sistema Operativo Destino (`execute-command.component`)**:
- Al ejecutar comandos de plugins específicos de un sistema operativo (como `ogfreeze` para Windows), si hay equipos apagados, desconectados o actualmente iniciados en un SO diferente (ej. `og-live`), se ofrece la confirmación de encolado (`QueueConfirmationModalComponent`) enviando el metadato `target_os`.
- La tarea encolada esperará de forma segura en `ogcore` a que el equipo arranque en el sistema operativo compatible antes de ser despachada.
## [1.16.0] - 2026-10-22
### Added
- **Soporte Dinámico para Plugins (`PluginService`, `PluginsComponent`, `PluginSettingsComponent`)**:
- Incorporada infraestructura agnóstica para descubrimiento y gestión de plugins desacoplados a través de `/api/plugins`.
- Agrupación ordenada de comandos de plugins en submenús dedicados en la consola de comandos (`execute-command`), organizados por plugin para mantener el menú limpio.
- Comprobación y filtrado dinámico de compatibilidad por Sistema Operativo (`os: ['windows']`), deshabilitando automáticamente los comandos en puestos con sistemas incompatibles.
- Estándar de Contrato y Acciones de Campos para Plugins (`PluginSettingsComponent`): Incorporado soporte para acciones declarativas por campo (`field.action`) ejecutadas contra el backend del plugin mediante el contrato estándar `{ config: ... }` -> `{ data: { [key]: value }, message: "..." }`. Los botones de asistencia (como la generación de scripts) se integran directamente en la cabecera de la caja del script y la lógica reside 100% en el backend del plugin, manteniendo el núcleo de `oggui` completamente agnóstico y desacoplado.
- Soporte de insignias de estado (badges) contextuales por cliente en las vistas de mosaico y lista de equipos (`groups.component`).
- Nuevo elemento condicional "Plugins" en el menú lateral (`sidebar`), visible únicamente cuando existen plugins activos en el sistema.
- Resaltador de sintaxis enriquecido y visor de terminal para scripts de configuración con numeración de líneas, colores semánticos (comentarios en verde itálica, comandos, parámetros, cadenas), botón de copiado rápido y alternancia entre vista coloreada y editor manual.
- Comportamiento 100% neutro e idéntico a 1.15.0 si no hay plugins instalados.
### Fixed
- **Recarga de página en Plugins (`plugins.component`, `plugin-settings.component`)**: Corregido el problema de sincronización reactiva al refrescar directamente (F5) la vista de `/plugins`, añadiendo un estado de carga visual y suscripción a `plugins$` para evitar mostrar prematuramente la pantalla de "No hay plugins instalados".
- **Rutas excluidas vacías en ogfreeze (`plugin-settings.component`)**: Corregida la regeneración de scripts cuando el campo de rutas excluidas se deja intencionadamente en blanco; ahora omite de forma limpia el bloque de exclusiones en lugar de forzar los valores predeterminados.
- **Ejecución y Validación de Comandos de Plugins (`execute-command.component`)**: Implementado el envío HTTP real de las órdenes de plugins (`POST /api/plugins/...`), validación de estado del SO activo (aviso si están ejecutando Linux u og-live cuando se requiere Windows) y diálogo de confirmación de encolado (`QueueConfirmationModalComponent`) cuando los equipos seleccionados se encuentran apagados o desconectados.
## [1.15.0] - 2026-10-20
### Added
- **Ejecución y Encolado Automático en Clientes Offline (`execute-command`, `boot-so-partition`, `queue-confirmation-modal`)**:
- Permitida la ejecución de comandos y scripts (incluyendo inicio de sesión en partición / arranque de SO) sobre equipos apagados o grupos con estados mixtos.
- El modal de confirmación detecta automáticamente equipos apagados o estados no ejecutables de inmediato, pre-seleccionando la opción de encolado y mostrando avisos visuales informativos (disponible en ES, EN y CA).
- Unificada y estandarizada la matriz de estados de todos los comandos en la consola de comandos.
### Fixed
- **Selección de Clientes en Unidades Organizativas (`groups.component.html`)**: Solucionado el problema al invocar comandos o tareas desde el menú contextual de un Aula / OU donde solo se seleccionaban los equipos mostrados en la página actual de la tabla en lugar de todos los clientes pertenecientes a la OU.
### Changed
- **Refactorización Angular (`boot-so-partition`)**: Migradas directivas estructurales (`*ngIf`, `*ngFor`) a la sintaxis moderna de control de flujo (`@if`, `@for`) y extraídos los estilos inline a clases CSS reutilizables.
## [1.14.4] - 2026-10-09
### Fixed
- **UI/UX (Componentes Genéricos)**: Solucionado un error en los componentes de añadir equipos a una subred (`add-clients-to-subnet`) y configuración de ficheros de arranque PXE (`pxe-boot-files`) introducido en versiones previas. Estos componentes enviaban un identificador URI (ej. `/organizational-units/uuid`) o el propio UUID en sus peticiones de búsqueda (GET) en lugar del ID numérico esperado por la base de datos (Doctrine), lo que impedía listar los equipos de las aulas seleccionadas. Ahora resuelven correctamente el ID numérico de la unidad seleccionada antes de realizar la petición al backend.
## [1.14.3] - 2026-10-09
### Fixed
- **UI/UX (Componentes Genéricos)**: Solucionado un problema de regresión introducido en la versión 1.14.2 donde múltiples componentes de creación y edición (Crear/Editar Tarea, Usuarios, Aula, Cliente, Crear Múltiples Clientes y Mover Clientes) enviaban un identificador numérico en el payload en lugar del identificador URI (`@id`) esperado por el backend para las relaciones de `organizationalUnit`, provocando errores al intentar guardar. Ahora, estos componentes detectan automáticamente el identificador devuelto por el selector genérico y construyen la URI correcta antes de realizar la petición HTTP.
## [1.14.2] - 2026-10-06
### Fixed
- **UI/UX (Componentes Genéricos)**: Refactorizado el comportamiento de `app-ou-select` para que devuelva siempre el identificador URI (`@id`) de la unidad organizativa en lugar de su ID numérico. Esto corrige varios errores críticos en cadena (como el error al mover clientes) provocados porque la API de Symfony (API Platform) requiere referencias URI (`/api/organizational_units/X`) para mapear entidades relacionales, en vez de identificadores enteros puros. Adicionalmente, el componente de mover clientes se ha hecho más robusto para prevenir envíos mal formateados.
## [1.14.1] - 2026-10-06
### Fixed
- **DHCP Subnets**: Solucionado un error al añadir equipos a una subred donde la petición al servidor enviaba la unidad organizativa como "undefined", impidiendo listar los equipos del aula seleccionada.
## [1.14.0] - 2026-10-05
### Added
- **UI/UX**: Añadido buscador en todos los desplegables de selección de Unidad Organizativa gracias al nuevo componente reutilizable `OuSelectComponent`.
### Changed
- **UI/UX**: Estandarizada la selección de Unidades Organizativas en toda la consola para mostrar de forma consistente la ruta jerárquica (breadcrumb) del elemento.
- **Refactor**: Extraída la lógica de selección de Unidades Organizativas a un componente genérico (`OuSelectComponent`) para cumplir con el principio DRY.
- **Refactor**: Migrada la sintaxis de plantillas (directivas estructurales `*ngIf`, `*ngFor`, `*ngSwitch`) a la nueva sintaxis de control de flujo de Angular 18 (`@if`, `@for`, `@switch`) en los componentes afectados por la refactorización.
## [1.13.0] - 2026-10-02
### Added
- **RemotePC**: Añadido un botón deslizable (toggle) en el panel de configuración de Unidades Organizativas (aulas) para habilitar el reinicio de equipos en lugar de apagado cuando son liberados por UDS.
### Fixed
- **UI/UX**: Solucionado un error de compilación y renderizado en `partition-assistant.component.css` causado por la inserción involuntaria de caracteres nulos (UTF-16) ocultos en el código fuente.
## [1.12.0] - 2026-09-30
### Added
- **UI/UX**: Implementada persistencia en localStorage para el ancho del panel lateral del árbol de jerarquías, individualizada por usuario.
- **RemotePC**: Añadida la insignia indicadora de conexión remota en la vista de lista (tabla) de clientes para unificar la experiencia con la vista de tarjetas.
### Changed
- **UI/UX**: Refinados los estilos visuales de la insignia de RemotePC (colores sutiles, bordes redondeados y tamaño reducido) para armonizar con el resto de etiquetas (ej: velocidad de red).
## [1.11.1] - 2026-09-24
### Fixed
- **UI Layout**: Corregido el problema de desbordamiento de barras de desplazamiento (scrollbars) globales forzando un diseño con flexbox en el layout principal de los clientes.
- **Jerarquía de Unidades Organizativas**: Se ha reemplazado la visualización del "padre inmediato" por una ruta recursiva completa (ej: Sede / Edificio / Planta / Aula) en los desplegables de aulas de ogDHCP y ogBoot.
- **Auto-selección de PXE**: Al elegir una partición destino en la ventana de "Despliegue de Imagen" y seleccionar "Cambiar Plantilla PXE", el sistema auto-seleccionará automáticamente la plantilla correspondiente al disco y partición (ej: `firstDisk_firstPartition`).
## [1.11.0] - 2026-09-23
### Added
- **Desplegar Imagen**: Añadidas acciones post-despliegue ("Iniciar Sistema Restaurado" y "Seleccionar Plantilla PXE") que permiten encadenar comandos de configuración de inicio y reinicio directamente tras programar el despliegue.
- **Preferencias de Usuario**: Las opciones elegidas para el despliegue (acción post-despliegue y tipo de PXE) se persisten automáticamente en el navegador de manera segura e individual por usuario.
## [1.10.2] - 2026-09-22
### Fixed
- **Crear/Desplegar Imagen**: Eliminada la dirección IP del listado de repositorios en los selectores de despliegue, mostrando únicamente el nombre del repositorio.
## [1.10.1] - 2026-09-18
### Fixed
- **Editor de Menús**: Corregida la previsualización del diseño HTML en entornos con proxy inverso (Nginx). Se delega la resolución de los 'assets' al backend proporcionándole el `baseUrl` completo, asegurando que las imágenes de los iconos se carguen correctamente tanto en la "Vista Global" como al previsualizar desde "Editar HTML".
## [1.10.0] - 2026-09-17
### Added
- **Permisos de Operador (ou-operator)**:
- Añadido permiso explícito para que los operadores de aula puedan ejecutar el comando `createImage`.
- Restringida la creación de nuevas unidades organizativas de tipo "Centro" para los usuarios con rol de operador.
## [1.9.0] - 2026-09-17
### Added
- **Visualización y Estructura Organizativa**:
- Implementados diferentes colores para las etiquetas de velocidad de las tarjetas de red en la vista de equipos, facilitando su identificación visual.
- Añadido un botón de búsqueda interactivo en el árbol organizativo (OUs) que permite ocultar/mostrar los campos de búsqueda para ahorrar espacio visual en pantalla.
- Componentes `pxe-boot-files` y `add-clients-to-subnet`: Añadido buscador en los menús desplegables (dropdowns) de selección de unidades organizativas y aulas, facilitando la búsqueda en jerarquías grandes.
- **Creación de Imágenes**:
- Permitida la creación de imágenes (`createImage`) a partir de particiones de datos que no contienen Sistema Operativo (Data partitions).
### Changed
- Refactorización de plantillas HTML en `groups`, `pxe-boot-files` y `add-clients-to-subnet` para migrar de directivas estructurales legacy (`*ngIf`, `*ngFor`) a la nueva sintaxis de control de flujo de Angular (`@if`, `@for`).
## [1.8.3] - 2026-07-31
### Added
- Inventario Hardware: Añadida visualización gráfica en la interfaz web para los nuevos campos reportados por los clientes.
- Las vistas "Tarjeta" y "Lista" de equipos muestran ahora un indicador ('badge') con la velocidad de la interfaz de red (ej. 1000 Mbps).
- El Asistente de Particionado muestra el tipo de disco físico ('SATA', 'NVMe', 'USB', etc.) junto al nombre del disco.
## [1.8.2] - 2026-07-27
### Added
- Task Logs (`task-logs.component`): Consume `output` and `finishedAt` properties from Mercure events to update trace outputs in real-time without reloading the page.
### Fixed
- Creación de imágenes (`create-image.component`): Al seleccionar la acción "Actualizar Imagen", ahora se filtran correctamente las imágenes para mostrar solo aquellas que pertenecen al repositorio objetivo asignado al cliente.
- Menú de opciones de cliente (`groups.component.html`): Unificado el menú de acciones (tres puntos verticales) usando `matMenuContent`. Se elimina la duplicidad de código que causaba divergencias de comportamiento entre la vista tarjeta y la vista lista. Ahora la opción de "Logs en tiempo real" está siempre disponible en ambas vistas, al igual que los bloqueos al sincronizar.
- Detalles de tareas (`OutputDialogComponent`): Ampliado el ancho de la ventana modal de detalles a `70vw` para facilitar la lectura de trazas largas y comandos de OGAgent/OGLive.
- Repositorios Git (`show-git-images.component`): Corregido el cierre abrupto de la ventana modal al borrar un repositorio. Ahora, la interfaz recarga la lista de repositorios, permitiendo seguir operando sin tener que volver a abrir el gestor.
## [1.8.1] - 2026-07-17
### Added
- Formulario de despliegue (`Deploy Image`): Añadido selector de repositorio origen (OG Repository) al menú de despliegue para permitir al usuario elegir desde dónde descargar la imagen.
### Changed
- Modal de partición de arranque (`boot-so-partition`): Refactorizado para usar el componente compartido `app-disk-partition-selector`, eliminando código duplicado y unificando el diseño y comportamiento de la selección de discos y particiones con el resto de modales.
### Fixed
- Detalles de tareas (`OutputDialogComponent`): Corregido el formateo de los detalles mostrados desde los agentes (OGAgent) para renderizar el texto en crudo manteniendo los saltos de línea (usando pre-wrap por CSS y <pre> tag), y dándole formato monoespaciado en lugar de intentar forzar un parseo estricto JSON que rompía la salida de comandos.
- Formulario de despliegue (`Deploy Image`): Solucionado el problema que ocultaba las comandos (instructions) cuando el despliegue era en modo caché. El código ha sido refactorizado y limpiado usando directivas estructurales estándar de Angular (`*ngIf`, `*ngFor`).
- Roles: Permisos actualizados en vistas para reflejar recientes cambios de backend en la gestión de perfiles de software.
## [1.8.0] - 2026-06-16
### Added
- Sistema de permisos por rol en la interfaz gráfica para los cuatro perfiles:
`ROLE_SUPER_ADMIN`, `ROLE_ORGANIZATIONAL_UNIT_ADMIN`,
`ROLE_ORGANIZATIONAL_UNIT_OPERATOR` y `ROLE_ORGANIZATIONAL_UNIT_MINIMAL`.
- `roleGuard`: protección de rutas según rol; ou-operator accede a operaciones
de equipos (despliegue, particionado, scripts, imagen); ou-minimal y
ou-operator son redirigidos a `/groups` si intentan acceder a rutas de
administración.
- Botones de creación/edición de infraestructura (DHCP, Boot servers,
repositorios, comandos) ocultos para ou-admin con `*ngIf`.
- Botones de creación de OU y cliente ocultos para ou-operator y ou-minimal.
- Tests unitarios de permisos: `auth.service.spec.ts` (5 roles × métodos de
rol), `role.guard.spec.ts` (matriz 26 rutas × 4 roles),
`sidebar.component.spec.ts` (visibilidad de ítems por rol).
- Infraestructura E2E con Playwright: `playwright.config.ts`, helpers de
autenticación y API, y 4 specs de permisos (super-admin, ou-admin,
ou-operator, ou-minimal) que verifican sidebar, rutas y botones.
- Botón de cerrar en la ventana de plano de aula.
### Improved
- La petición de equipos al hacer clic en un aula y la actualización de su
estado se ejecutan de forma secuencial en lugar de en paralelo, evitando
el loader general mientras se resuelven los estados individuales.
- Ficheros de idiomas (es, en, cat) ordenados alfabéticamente; revisión
completa del fichero catalán.
## [1.7.0] - 2026-05-14
### Added
- Visor de logs de disco en las páginas de estado de ogcore, ogboot, ogdhcp
y ogrepository: muestra entries del journal en tiempo real sin necesidad
de oglog/OpenSearch, con polling cada 5 segundos y modo live/pausa
- Soporte de múltiples `SYSLOG_IDENTIFIER` por componente: ogdhcp incluye
kea, ogrepository incluye samba, ogboot incluye tftpd-hpa
- Host basado en IP del servidor seleccionado para lectura del fichero de
journal remoto (`/var/log/journal/remote/<IP>.journal`)
### Changed
- `StatusLogsComponent`: `@Input() identifier` reemplazado por
`@Input() identifiers: string[]`; respuesta parseada como array de entries
JSON estructurados con formato `[timestamp] IDENTIFIER: mensaje`
## [1.6.1] - 2026-05-14
### Improved
- Modificada recepcion de mensajes de mercure durante la importación
## [1.6.0] - 2026-05-08
### Added
- Añadida opción en Administración para importar una base de datos OG 1.1 en la nueva version. Permite subir fichero .sql o .zip o usar parametros de conexión a la base de datos de OG 1.1
## [1.5.9] - 2026-05-07
### Fixed
- Asistente de particionado: en discos GPT se elimina el límite de 4 particiones primarias, se oculta la opción de añadir partición lógica/extendida y se suprime el estilo visual de partición lógica (borde amarillo e icono └)
@ -430,3 +802,4 @@
### Fixed
- Resolved an issue that prevented editing software profiles correctly.
- Fixed a bug where newly created commands failed to execute in the commands section.

View File

@ -61,6 +61,19 @@ pipeline {
}
}
stage('Generate Changelog (Devel)') {
when {
branch 'devel'
}
steps {
script {
def devName = params.DEV_NAME ? params.DEV_NAME : env.DEFAULT_DEV_NAME
def devEmail = params.DEV_EMAIL ? params.DEV_EMAIL : env.DEFAULT_DEV_EMAIL
generateDebianChangelog(env.BUILD_DIR, devName, devEmail, "devel")
}
}
}
stage('Build') {
steps {
@ -97,20 +110,38 @@ pipeline {
}
}
}
stage ('Publish to Debian Repository (Devel)') {
when {
branch 'devel'
}
agent { label 'debian-repo' }
steps {
script {
def versionPattern = "-${env.BUILD_NUMBER}~devel"
publicarEnAptly('/var/tmp/opengnsys/debian-repo/oggui', 'devel', versionPattern)
}
}
}
}
post {
success {
script {
// Solo lanzar cuando el build sea exitoso y en la rama main
if (env.BRANCH_NAME == 'main') {
build job: 'Aptly publish nightly repository',
wait: false,
parameters: [
string(name: 'TRIGGERED_BY', value: "${env.JOB_NAME}-${env.BUILD_NUMBER}")
]
build job: 'Aptly publish nightly repository',
wait: false,
parameters: [
string(name: 'TRIGGERED_BY', value: "${env.JOB_NAME}-${env.BUILD_NUMBER}")
]
}
if (env.BRANCH_NAME == 'devel') {
build job: 'Aptly publish develop repository',
wait: false,
parameters: [
string(name: 'TRIGGERED_BY', value: "${env.JOB_NAME}-${env.BUILD_NUMBER}")
]
}
}
}
always {
notifyBuildStatus('opengnsys@qindel.com')

View File

@ -43,3 +43,5 @@ Thumbs.db
test-results/
.e2e-state.json
playwright-report/

View File

@ -0,0 +1,7 @@
import { setupTestUsers } from './helpers/api';
export default async function globalSetup() {
console.log('\n▶ E2E — creando usuarios de test...');
await setupTestUsers();
console.log(' ✓ Setup completado\n');
}

View File

@ -0,0 +1,7 @@
import { teardownTestUsers } from './helpers/api';
export default async function globalTeardown() {
console.log('\n▶ E2E — eliminando usuarios de test...');
await teardownTestUsers();
console.log(' ✓ Teardown completado\n');
}

View File

@ -0,0 +1,139 @@
/**
* Helpers para crear y borrar usuarios de test vía la API de ogCore.
* Se usan en globalSetup / globalTeardown de Playwright.
*/
import { Agent } from 'undici';
import * as fs from 'fs';
import * as path from 'path';
export const API_URL = process.env['API_URL'] || 'https://ogserver.local/api';
export const APP_URL = process.env['APP_URL'] || 'https://localhost:4200/oggui';
export const ADMIN_USER = process.env['ADMIN_USER'] || 'ogadmin';
export const ADMIN_PASS = process.env['ADMIN_PASS'] || '12345678';
export const TEST_PASS = 'Test1234!';
// Nombres fijos — sin timestamp para que sean consistentes entre procesos
export const TEST_USERS = {
superAdmin: { username: 'e2e-superadmin', role: 'ROLE_SUPER_ADMIN' },
ouAdmin: { username: 'e2e-ouadmin', role: 'ROLE_ORGANIZATIONAL_UNIT_ADMIN' },
operator: { username: 'e2e-operator', role: 'ROLE_ORGANIZATIONAL_UNIT_OPERATOR' },
minimal: { username: 'e2e-minimal', role: 'ROLE_ORGANIZATIONAL_UNIT_MINIMAL' },
};
// Fichero temporal para pasar IRIs del setup al teardown
const STATE_FILE = path.join(__dirname, '../../.e2e-state.json');
// undici Agent que ignora certificados autofirmados (equivalente a curl -k)
const insecureAgent = new Agent({ connect: { rejectUnauthorized: false } });
async function fetchJson(url: string, init: RequestInit): Promise<any> {
const res = await fetch(url, {
...init,
headers: { 'Content-Type': 'application/ld+json', ...(init.headers as any) },
dispatcher: insecureAgent,
} as any);
const text = await res.text();
try { return JSON.parse(text); } catch { return text; }
}
export async function getAdminToken(): Promise<string> {
const data = await fetchJson(`${API_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: ADMIN_USER, password: ADMIN_PASS }),
});
if (!data.token) throw new Error(`Login fallido: ${JSON.stringify(data)}`);
return data.token;
}
async function findUserByUsername(token: string, username: string): Promise<string | null> {
const data = await fetchJson(`${API_URL}/users?username=${encodeURIComponent(username)}`, {
method: 'GET',
headers: { Authorization: `Bearer ${token}` },
});
const members = data['hydra:member'] ?? [];
return members.length > 0 ? members[0]['@id'] : null;
}
async function findUserGroupByName(token: string, name: string): Promise<string | null> {
const data = await fetchJson(`${API_URL}/user-groups?name=${encodeURIComponent(name)}`, {
method: 'GET',
headers: { Authorization: `Bearer ${token}` },
});
const members = data['hydra:member'] ?? [];
return members.length > 0 ? members[0]['@id'] : null;
}
async function deleteResource(token: string, iri: string): Promise<void> {
await fetch(`${API_URL.replace('/api', '')}${iri}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
dispatcher: insecureAgent,
} as any);
}
async function createUserGroup(token: string, role: string, name: string): Promise<string> {
const data = await fetchJson(`${API_URL}/user-groups`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify({ name, permissions: [role], enabled: true }),
});
if (!data['@id']) throw new Error(`No se pudo crear UserGroup ${name}: ${JSON.stringify(data)}`);
return data['@id'];
}
async function createUser(token: string, username: string, ugIri: string): Promise<string> {
const data = await fetchJson(`${API_URL}/users`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify({ username, password: TEST_PASS, enabled: true, userGroups: [ugIri] }),
});
if (!data['@id']) throw new Error(`No se pudo crear usuario ${username}: ${JSON.stringify(data)}`);
return data['@id'];
}
export async function setupTestUsers(): Promise<void> {
const token = await getAdminToken();
const userIris: string[] = [];
const ugIris: string[] = [];
for (const { username, role } of Object.values(TEST_USERS)) {
const ugName = `e2e-ug-${username}`;
// Eliminar usuario previo si existe (limpia runs anteriores)
const existingUser = await findUserByUsername(token, username);
if (existingUser) {
await deleteResource(token, existingUser);
console.log(` ↺ Eliminado usuario previo: ${username}`);
}
// Eliminar UserGroup previo si existe
const existingUg = await findUserGroupByName(token, ugName);
if (existingUg) {
await deleteResource(token, existingUg);
}
const ugIri = await createUserGroup(token, role, ugName);
const userIri = await createUser(token, username, ugIri);
ugIris.push(ugIri);
userIris.push(userIri);
console.log(` ✓ ${username} [${role}]`);
}
// Guarda IRIs en fichero para que teardown pueda leerlos (env vars no siempre pasan entre procesos)
fs.writeFileSync(STATE_FILE, JSON.stringify({ userIris, ugIris }, null, 2));
}
export async function teardownTestUsers(): Promise<void> {
if (!fs.existsSync(STATE_FILE)) return;
const { userIris, ugIris } = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8'));
const token = await getAdminToken();
for (const iri of [...userIris, ...ugIris]) {
await deleteResource(token, iri);
console.log(` ✓ Eliminado: ${iri}`);
}
fs.unlinkSync(STATE_FILE);
}

View File

@ -0,0 +1,39 @@
import { Page, expect } from '@playwright/test';
import { APP_URL, TEST_PASS, TEST_USERS } from './api';
export type Role = keyof typeof TEST_USERS;
/**
* Hace login en la GUI con el usuario de test del rol indicado.
* Espera a que la redirección post-login termine (URL cambia a /groups).
*/
export async function loginAs(page: Page, role: Role): Promise<void> {
const { username } = TEST_USERS[role];
await page.goto(`${APP_URL}/auth/login`);
await page.locator('input[name="username"]').fill(username);
await page.locator('input[name="password"]').fill(TEST_PASS);
await page.locator('button[type="submit"]').click();
await expect(page).toHaveURL(/\/groups/, { timeout: 10_000 });
}
/**
* Navega a una ruta y devuelve la URL final (útil para comprobar redirecciones).
*/
export async function navigateTo(page: Page, path: string): Promise<string> {
await page.goto(`${APP_URL}${path}`);
await page.waitForLoadState('networkidle');
return page.url();
}
/** Comprueba que un enlace del sidebar ES visible */
export async function expectSidebarLink(page: Page, routerLink: string, visible: true): Promise<void>;
/** Comprueba que un enlace del sidebar NO está en el DOM */
export async function expectSidebarLink(page: Page, routerLink: string, visible: false): Promise<void>;
export async function expectSidebarLink(page: Page, routerLink: string, visible: boolean): Promise<void> {
const locator = page.locator(`[routerLink="${routerLink}"], a[href="${routerLink}"]`).first();
if (visible) {
await expect(locator).toBeVisible({ timeout: 5_000 });
} else {
await expect(locator).not.toBeAttached({ timeout: 5_000 });
}
}

View File

@ -0,0 +1,75 @@
import { test, expect } from '@playwright/test';
import { loginAs, navigateTo, expectSidebarLink } from '../helpers/auth';
import { APP_URL } from '../helpers/api';
test.describe('OU Admin — acceso a gestión sin infraestructura', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, 'ouAdmin');
});
// ── Sidebar ─────────────────────────────────────────────────────────────────
test.describe('Sidebar', () => {
test('ve /groups', async ({ page }) => expectSidebarLink(page, '/groups', true));
test('ve /dhcp-servers', async ({ page }) => expectSidebarLink(page, '/dhcp-servers', true));
test('ve /boot-servers', async ({ page }) => expectSidebarLink(page, '/boot-servers', true));
test('ve /calendars', async ({ page }) => expectSidebarLink(page, '/calendars', true));
test('ve /repositories', async ({ page }) => expectSidebarLink(page, '/repositories', true));
test('ve /menus', async ({ page }) => expectSidebarLink(page, '/menus', true));
});
// ── Rutas permitidas ─────────────────────────────────────────────────────────
test.describe('Rutas — puede acceder', () => {
const allowed = [
'/groups', '/commands', '/dhcp-servers', '/boot-servers',
'/calendars', '/repositories', '/pxe',
];
for (const route of allowed) {
test(`accede a ${route}`, async ({ page }) => {
const finalUrl = await navigateTo(page, route);
expect(finalUrl).toContain(route);
});
}
});
// ── Rutas bloqueadas (solo super-admin) ──────────────────────────────────────
test.describe('Rutas — redirige a /groups cuando accede a rutas de super-admin', () => {
const blocked = ['/users', '/roles', '/env-vars', '/import-legacy'];
for (const route of blocked) {
test(`${route} → redirige a /groups`, async ({ page }) => {
const finalUrl = await navigateTo(page, route);
expect(finalUrl).toContain('/groups');
});
}
});
// ── GUI: botones de infraestructura OCULTOS ──────────────────────────────────
test('NO ve el botón "Añadir OU" en /groups (solo ou-admin no puede crear desde aquí... wait, sí puede)', async ({ page }) => {
await page.goto(`${APP_URL}/groups`);
// ou-admin SÍ ve el botón de añadir OU (no es ou-operator ni ou-minimal)
const addOuBtn = page.locator('button.action-button').first();
await expect(addOuBtn).toBeVisible({ timeout: 5_000 });
});
test('NO ve botón crear en /dhcp-servers (solo super-admin puede crear servidores DHCP)', async ({ page }) => {
await page.goto(`${APP_URL}/dhcp-servers`);
await page.waitForLoadState('networkidle');
// El botón de añadir dhcp tiene *ngIf="auth.isSuperAdmin" → no debe aparecer
const addBtn = page.locator('button[color="primary"]').filter({ hasText: /add|añadir/i });
await expect(addBtn).not.toBeAttached({ timeout: 5_000 });
});
test('NO ve botón crear en /boot-servers (solo super-admin)', async ({ page }) => {
await page.goto(`${APP_URL}/boot-servers`);
await page.waitForLoadState('networkidle');
const addBtn = page.locator('button').filter({ hasText: /add|añadir/i }).first();
await expect(addBtn).not.toBeAttached({ timeout: 5_000 });
});
test('NO ve botón crear en /commands (solo super-admin)', async ({ page }) => {
await page.goto(`${APP_URL}/commands`);
await page.waitForLoadState('networkidle');
const addBtn = page.locator('button.action-button');
await expect(addBtn).not.toBeAttached({ timeout: 5_000 });
});
});

View File

@ -0,0 +1,60 @@
import { test, expect } from '@playwright/test';
import { loginAs, navigateTo, expectSidebarLink } from '../helpers/auth';
import { APP_URL } from '../helpers/api';
test.describe('OU Minimal — solo vista de grupos', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, 'minimal');
});
// ── Login ────────────────────────────────────────────────────────────────────
test('tras login aterriza en /groups', async ({ page }) => {
await expect(page).toHaveURL(/\/groups/);
});
// ── Sidebar: solo /groups ────────────────────────────────────────────────────
test.describe('Sidebar — solo ve /groups', () => {
test('ve /groups', async ({ page }) => expectSidebarLink(page, '/groups', true));
test('NO ve /dhcp-servers', async ({ page }) => expectSidebarLink(page, '/dhcp-servers', false));
test('NO ve /boot-servers', async ({ page }) => expectSidebarLink(page, '/boot-servers', false));
test('NO ve /calendars', async ({ page }) => expectSidebarLink(page, '/calendars', false));
test('NO ve /repositories', async ({ page }) => expectSidebarLink(page, '/repositories', false));
test('NO ve /menus', async ({ page }) => expectSidebarLink(page, '/menus', false));
});
// ── Rutas bloqueadas ─────────────────────────────────────────────────────────
test.describe('Todas las rutas protegidas redirigen a /groups', () => {
const blocked = [
'/users', '/roles', '/env-vars',
'/commands', '/dhcp-servers', '/boot-servers',
'/calendars', '/repositories', '/pxe',
'/clients/deploy-image', '/clients/partition-assistant', '/clients/run-script',
];
for (const route of blocked) {
test(`${route} → redirige a /groups`, async ({ page }) => {
const finalUrl = await navigateTo(page, route);
expect(finalUrl).toContain('/groups');
});
}
});
// ── GUI en /groups: sin botones de acción ────────────────────────────────────
test.describe('/groups — sin botones de creación ni edición', () => {
test('NO ve botones "Añadir OU" ni "Añadir cliente"', async ({ page }) => {
await page.goto(`${APP_URL}/groups`);
await page.waitForLoadState('networkidle');
const actionBtns = page.locator('button.action-button');
await expect(actionBtns).not.toBeAttached({ timeout: 5_000 });
});
test('NO ve botones de editar ni eliminar en el árbol de grupos', async ({ page }) => {
await page.goto(`${APP_URL}/groups`);
await page.waitForLoadState('networkidle');
// Los botones de editar/borrar nodo tienen *ngIf="auth.userCategory !== 'ou-minimal'"
// Se verifican a través del menú contextual que no debería aparecer con opciones de edición
const editBtns = page.locator('button').filter({ hasText: /editar|edit|modificar/i });
await expect(editBtns).not.toBeAttached({ timeout: 5_000 });
});
});
});

View File

@ -0,0 +1,82 @@
import { test, expect } from '@playwright/test';
import { loginAs, navigateTo, expectSidebarLink } from '../helpers/auth';
import { APP_URL } from '../helpers/api';
test.describe('OU Operator — solo grupos y operaciones de equipos', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, 'operator');
});
// ── Sidebar: solo /groups visible ───────────────────────────────────────────
test.describe('Sidebar — solo ve /groups', () => {
test('ve /groups', async ({ page }) => expectSidebarLink(page, '/groups', true));
test('NO ve /dhcp-servers', async ({ page }) => expectSidebarLink(page, '/dhcp-servers', false));
test('NO ve /boot-servers', async ({ page }) => expectSidebarLink(page, '/boot-servers', false));
test('NO ve /calendars', async ({ page }) => expectSidebarLink(page, '/calendars', false));
test('NO ve /repositories', async ({ page }) => expectSidebarLink(page, '/repositories', false));
test('NO ve /menus', async ({ page }) => expectSidebarLink(page, '/menus', false));
});
// ── Rutas bloqueadas: redirige a /groups ─────────────────────────────────────
test.describe('Rutas de admin → redirigen a /groups', () => {
const blocked = [
'/users', '/roles', '/env-vars',
'/commands', '/dhcp-servers', '/boot-servers',
'/calendars', '/repositories', '/pxe',
];
for (const route of blocked) {
test(`${route} → redirige a /groups`, async ({ page }) => {
const finalUrl = await navigateTo(page, route);
expect(finalUrl).toContain('/groups');
});
}
});
// ── Rutas permitidas: operaciones de equipos ─────────────────────────────────
test.describe('Rutas de operación — puede acceder', () => {
test('accede a /groups', async ({ page }) => {
const finalUrl = await navigateTo(page, '/groups');
expect(finalUrl).toContain('/groups');
});
test('accede a /clients/deploy-image', async ({ page }) => {
const finalUrl = await navigateTo(page, '/clients/deploy-image');
expect(finalUrl).toContain('/clients/deploy-image');
});
test('accede a /clients/partition-assistant', async ({ page }) => {
const finalUrl = await navigateTo(page, '/clients/partition-assistant');
expect(finalUrl).toContain('/clients/partition-assistant');
});
test('accede a /clients/run-script', async ({ page }) => {
const finalUrl = await navigateTo(page, '/clients/run-script');
expect(finalUrl).toContain('/clients/run-script');
});
test('accede a /commands-logs (trazas de sus acciones)', async ({ page }) => {
const finalUrl = await navigateTo(page, '/commands-logs');
expect(finalUrl).toContain('/commands-logs');
});
});
// ── GUI en /groups: botones de crear OU/cliente ocultos ─────────────────────
test.describe('/groups — botones de creación ocultos', () => {
test('NO ve el botón "Añadir OU"', async ({ page }) => {
await page.goto(`${APP_URL}/groups`);
await page.waitForLoadState('networkidle');
// *ngIf="auth.userCategory !== 'ou-operator' && ..."
const addOuBtn = page.locator('button.action-button').filter({ hasText: /ou|unidad/i });
await expect(addOuBtn).not.toBeAttached({ timeout: 5_000 });
});
test('NO ve el botón de menú "Añadir cliente"', async ({ page }) => {
await page.goto(`${APP_URL}/groups`);
await page.waitForLoadState('networkidle');
// Los botones de acción principales no deben aparecer para operator
const actionBtns = page.locator('button.action-button');
await expect(actionBtns).not.toBeAttached({ timeout: 5_000 });
});
});
});

View File

@ -0,0 +1,66 @@
import { test, expect } from '@playwright/test';
import { loginAs, navigateTo, expectSidebarLink } from '../helpers/auth';
import { APP_URL } from '../helpers/api';
test.describe('Super Admin — acceso total', () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, 'superAdmin');
});
// ── Sidebar: todos los elementos visibles ───────────────────────────────────
test.describe('Sidebar — ve todos los enlaces', () => {
const links = [
'/groups', '/dhcp-servers', '/boot-servers',
'/calendars', '/repositories', '/menus',
];
for (const link of links) {
test(`ve el enlace a ${link}`, async ({ page }) => {
await expectSidebarLink(page, link, true);
});
}
});
// ── Rutas: acceso permitido ─────────────────────────────────────────────────
test.describe('Rutas — puede acceder a todas', () => {
const routes = [
'/users', '/roles', '/env-vars',
'/commands', '/pxe', '/dhcp-servers', '/boot-servers',
'/calendars', '/repositories',
];
for (const route of routes) {
test(`accede a ${route} sin ser redirigido`, async ({ page }) => {
const finalUrl = await navigateTo(page, route);
expect(finalUrl).toContain(route);
});
}
});
// ── GUI: botones de infraestructura visibles ────────────────────────────────
test('ve el botón "Añadir OU" en /groups', async ({ page }) => {
await page.goto(`${APP_URL}/groups`);
await expect(page.locator('button.action-button').first()).toBeVisible();
});
test('ve botones de crear/editar en /dhcp-servers', async ({ page }) => {
await page.goto(`${APP_URL}/dhcp-servers`);
await page.waitForLoadState('networkidle');
// Botón de añadir servidor (solo visible para super-admin)
const addBtn = page.locator('button').filter({ hasText: /add|añadir|nuevo/i }).first();
await expect(addBtn).toBeVisible({ timeout: 8_000 });
});
test('ve botones de crear en /commands', async ({ page }) => {
await page.goto(`${APP_URL}/commands`);
await page.waitForLoadState('networkidle');
const addBtn = page.locator('button.action-button').first();
await expect(addBtn).toBeVisible({ timeout: 8_000 });
});
test('ve botones de crear en /boot-servers', async ({ page }) => {
await page.goto(`${APP_URL}/boot-servers`);
await page.waitForLoadState('networkidle');
const addBtn = page.locator('button').filter({ hasText: /add|añadir|nuevo/i }).first();
await expect(addBtn).toBeVisible({ timeout: 8_000 });
});
});

View File

@ -1,12 +1,12 @@
{
"name": "og-webconsole",
"version": "0.5.0",
"version": "1.14.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "og-webconsole",
"version": "0.5.0",
"version": "1.14.0",
"dependencies": {
"@angular/animations": "^18.0.0",
"@angular/cdk": "~18.0.0",
@ -35,6 +35,7 @@
"@angular/compiler-cli": "^18.0.0",
"@angular/localize": "^18.1.0",
"@ngx-env/builder": "^18.0.1",
"@playwright/test": "^1.60.0",
"@types/jasmine": "~5.1.0",
"@types/papaparse": "^5.3.15",
"jasmine-core": "~5.1.0",
@ -272,6 +273,7 @@
"version": "18.1.0",
"resolved": "https://registry.npmjs.org/@angular/animations/-/animations-18.1.0.tgz",
"integrity": "sha512-K0BhvZ/SIVoGXZVuh1KOJDdgcGlHfFGMGrs58utndndAb+gYXReMfz4GR5cQs2OObH6TKmIOY2EH7Og1CY2tsw==",
"peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@ -356,6 +358,7 @@
"version": "18.0.6",
"resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-18.0.6.tgz",
"integrity": "sha512-9CmlQ8uZ0cHuj+1jDdabbFw90QpjrJzh1/cgeHN/8vAGE4ranxX3l6cqpVxuoWRLkB2ykTHt2xeoTM8y0d9ERA==",
"peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@ -405,6 +408,7 @@
"version": "18.1.0",
"resolved": "https://registry.npmjs.org/@angular/common/-/common-18.1.0.tgz",
"integrity": "sha512-noHDLarQSCZZh7hyNd0HR61Fut+q4QCVq9qc/jKPglfbV/6nPujQSmSpT+rNJlNuBOrCLuvH/CNBNbiqii+x3g==",
"peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@ -420,6 +424,7 @@
"version": "18.1.0",
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-18.1.0.tgz",
"integrity": "sha512-JRQzVTeJGSfRLY+dx+gwu/hPQVB8K+5pW12Z42M9x/HBgGW4in0cO2zHkeQPvImqm0nak82Us1Hyf5C+qTlMMQ==",
"peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@ -440,6 +445,7 @@
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-18.1.0.tgz",
"integrity": "sha512-BBsogLPJwxkPh7f8RVHsxyyqNE8XpHbAanjB5fAwnU4W6Sw1kR5rFzkeZM3xaRm2MDiC8DovIl6hlf+s/mKYOw==",
"dev": true,
"peer": true,
"dependencies": {
"@babel/core": "7.24.7",
"@jridgewell/sourcemap-codec": "^1.4.14",
@ -467,6 +473,7 @@
"version": "18.1.0",
"resolved": "https://registry.npmjs.org/@angular/core/-/core-18.1.0.tgz",
"integrity": "sha512-/57/s7CD/0CwlN+3FlhVmx7ypCWXjKi5UKtnlBAUg0D1denIf6ADxwTHFZABYZcYBqOTJgeQUtUw9u/A+0CIlg==",
"peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@ -482,6 +489,7 @@
"version": "18.1.0",
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-18.1.0.tgz",
"integrity": "sha512-m+7m9wa+n5dEacd458eSZsZTz0B+HbOtr7/uqM0YTMQaPrhwl1epG5Y103mB6yr00JiJcLNlPLjP888cHFjldQ==",
"peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@ -500,6 +508,7 @@
"resolved": "https://registry.npmjs.org/@angular/localize/-/localize-18.1.0.tgz",
"integrity": "sha512-84D06p2Th5NxoJZzsSIn4FkTJGImj7rtNnvyTrHvHdomzzUKwiBOXDB2FiCLDstND0DsCtgjD/uBJivg77z9tg==",
"dev": true,
"peer": true,
"dependencies": {
"@babel/core": "7.24.7",
"@types/babel__core": "7.20.5",
@ -588,6 +597,7 @@
"version": "18.1.0",
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-18.1.0.tgz",
"integrity": "sha512-jCmxthiI4Zef54crckNht60xwfIsuciGeyZvb7SsXna2maLW9fA4uz1VhZqIWTiBnHwNynVlyfBX3/jBD7S9+g==",
"peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@ -609,6 +619,7 @@
"version": "18.1.0",
"resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-18.1.0.tgz",
"integrity": "sha512-D/wuOQf+gULld9DVEzn2Lw3WbTyAYf/sp3DC5k83O+DQsG3eAIsVkt0zdE+U3DrDYsiWg8M3X+ioi3ouqK0mNg==",
"peer": true,
"dependencies": {
"tslib": "^2.3.0"
},
@ -666,6 +677,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.7.tgz",
"integrity": "sha512-nykK+LEK86ahTkX/3TgauT0ikKoNCfKHEaZYTUVupJdTLzGNvrblu4u6fa7DhZONAltdf8e662t/abY8idrd/g==",
"dev": true,
"peer": true,
"dependencies": {
"@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.24.7",
@ -3217,6 +3229,7 @@
"resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-5.0.7.tgz",
"integrity": "sha512-GFcigCxJTKCH3aECzMIu4FhgLJWnFvMXzpI4CCSoELWFtkOOU2P+goYA61+OKpGrB8fPE7q6n8zAXBSlZRrHjQ==",
"dev": true,
"peer": true,
"dependencies": {
"@inquirer/checkbox": "^2.3.7",
"@inquirer/confirm": "^3.1.11",
@ -4518,7 +4531,6 @@
"os": [
"aix"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4535,7 +4547,6 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4552,7 +4563,6 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4569,7 +4579,6 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4586,7 +4595,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4603,7 +4611,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4620,7 +4627,6 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4637,7 +4643,6 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4654,7 +4659,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4671,7 +4675,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4688,7 +4691,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4705,7 +4707,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4722,7 +4723,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4739,7 +4739,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4756,7 +4755,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4773,7 +4771,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4790,7 +4787,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4807,7 +4803,6 @@
"os": [
"netbsd"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4824,7 +4819,6 @@
"os": [
"openbsd"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4841,7 +4835,6 @@
"os": [
"sunos"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4858,7 +4851,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4875,7 +4867,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -4892,7 +4883,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">=12"
}
@ -5306,6 +5296,22 @@
"node": ">=14"
}
},
"node_modules/@playwright/test": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.18.0.tgz",
@ -5891,6 +5897,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.10.tgz",
"integrity": "sha512-MdiXf+nDuMvY0gJKxyfZ7/6UFsETO7mGKF54MVD/ekJS6HdFtpZFBgrh6Pseu64XTb2MLyFPlbW6hj8HYRQNOQ==",
"dev": true,
"peer": true,
"dependencies": {
"undici-types": "~5.26.4"
}
@ -6188,6 +6195,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz",
"integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==",
"dev": true,
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -6261,6 +6269,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.16.0.tgz",
"integrity": "sha512-F0twR8U1ZU67JIEtekUcLkXkoO5mMMmgGD8sK/xUFzJ805jxHQl92hImFAqqXMyMYjSPOyUPAwHYhB72g5sTXw==",
"dev": true,
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"json-schema-traverse": "^1.0.0",
@ -6666,6 +6675,7 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"caniuse-lite": "^1.0.30001640",
"electron-to-chromium": "^1.4.820",
@ -7725,6 +7735,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"peer": true,
"engines": {
"node": ">=12"
}
@ -9810,7 +9821,8 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.1.2.tgz",
"integrity": "sha512-2oIUMGn00FdUiqz6epiiJr7xcFyNYj3rDcfmnzfkBnHyBQ3cBQUs4mmyGsOb7TTLb9kxk7dBcmEmqhDKkBoDyA==",
"dev": true
"dev": true,
"peer": true
},
"node_modules/jest-worker": {
"version": "27.5.1",
@ -9959,6 +9971,7 @@
"resolved": "https://registry.npmjs.org/karma/-/karma-6.4.3.tgz",
"integrity": "sha512-LuucC/RE92tJ8mlCwqEoRWXP38UMAqpnq98vktmS9SznSoUPPUJQbc91dHcxcunROvfQjdORVA/YFviH+Xci9Q==",
"dev": true,
"peer": true,
"dependencies": {
"@colors/colors": "1.5.0",
"body-parser": "^1.19.0",
@ -10259,6 +10272,7 @@
"resolved": "https://registry.npmjs.org/less/-/less-4.2.0.tgz",
"integrity": "sha512-P3b3HJDBtSzsXUl0im2L7gTO5Ubg8mEN6G8qoTS77iXxXX4Hvu4Qj540PZDvQ8V6DmX6iXo98k7Md0Cm1PrLaA==",
"dev": true,
"peer": true,
"dependencies": {
"copy-anything": "^2.0.1",
"parse-node-version": "^1.0.1",
@ -12188,6 +12202,53 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.4.38",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz",
@ -12207,6 +12268,7 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"nanoid": "^3.3.7",
"picocolors": "^1.0.0",
@ -12823,6 +12885,7 @@
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
"integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==",
"peer": true,
"dependencies": {
"tslib": "^2.1.0"
}
@ -13803,6 +13866,7 @@
"resolved": "https://registry.npmjs.org/terser/-/terser-5.29.2.tgz",
"integrity": "sha512-ZiGkhUBIM+7LwkNjXYJq8svgkd+QK3UUr0wJqY4MieaezBSAIPgbSPZyIx0idM6XWK5CMzSWa8MJIzmRcB8Caw==",
"dev": true,
"peer": true,
"dependencies": {
"@jridgewell/source-map": "^0.3.3",
"acorn": "^8.8.2",
@ -13855,6 +13919,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@ -13987,7 +14052,8 @@
"node_modules/tslib": {
"version": "2.6.3",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz",
"integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ=="
"integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==",
"peer": true
},
"node_modules/tuf-js": {
"version": "2.2.1",
@ -14039,6 +14105,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
"dev": true,
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -14284,6 +14351,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-5.3.2.tgz",
"integrity": "sha512-6lA7OBHBlXUxiJxbO5aAY2fsHHzDr1q7DvXYnyZycRs2Dz+dXBWuhpWHvmljTRTpQC2uvGmUFFkSHF2vGo90MA==",
"dev": true,
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.38",
@ -14385,6 +14453,7 @@
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.92.1.tgz",
"integrity": "sha512-JECQ7IwJb+7fgUFBlrJzbyu3GEuNBcdqr1LD7IbSzwkSmIevTm8PF+wej3Oxuz/JFBUZ6O1o43zsPkwm1C4TmA==",
"dev": true,
"peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.3",
"@types/estree": "^1.0.5",
@ -14461,6 +14530,7 @@
"resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.0.4.tgz",
"integrity": "sha512-dljXhUgx3HqKP2d8J/fUMvhxGhzjeNVarDLcbO/EWMSgRizDkxHQDZQaLFL5VJY9tRBj2Gz+rvCEYYvhbqPHNA==",
"dev": true,
"peer": true,
"dependencies": {
"@types/bonjour": "^3.5.13",
"@types/connect-history-api-fallback": "^1.5.4",
@ -14650,6 +14720,7 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@ -15031,7 +15102,8 @@
"node_modules/zone.js": {
"version": "0.14.7",
"resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.14.7.tgz",
"integrity": "sha512-0w6DGkX2BPuiK/NLf+4A8FLE43QwBfuqz2dVgi/40Rj1WmqUskCqj329O/pwrqFJLG5X8wkeG2RhIAro441xtg=="
"integrity": "sha512-0w6DGkX2BPuiK/NLf+4A8FLE43QwBfuqz2dVgi/40Rj1WmqUskCqj329O/pwrqFJLG5X8wkeG2RhIAro441xtg==",
"peer": true
}
}
}

View File

@ -1,12 +1,16 @@
{
"name": "og-webconsole",
"version": "0.5.0",
"version": "1.26.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
"test": "ng test",
"e2e": "playwright test",
"e2e:headed": "playwright test --headed --slow-mo=400",
"e2e:ui": "playwright test --ui",
"e2e:report": "playwright show-report"
},
"private": true,
"dependencies": {
@ -37,6 +41,7 @@
"@angular/compiler-cli": "^18.0.0",
"@angular/localize": "^18.1.0",
"@ngx-env/builder": "^18.0.1",
"@playwright/test": "^1.60.0",
"@types/jasmine": "~5.1.0",
"@types/papaparse": "^5.3.15",
"jasmine-core": "~5.1.0",

View File

@ -0,0 +1,33 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
globalSetup: './e2e/global-setup.ts',
globalTeardown: './e2e/global-teardown.ts',
timeout: 30_000,
retries: 0,
workers: 1, // secuencial para no crear conflictos entre usuarios de test
reporter: [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]],
use: {
baseURL: 'https://localhost:4200/oggui',
ignoreHTTPSErrors: true,
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
webServer: {
command: 'npm start',
url: 'https://localhost:4200/oggui',
reuseExistingServer: true,
ignoreHTTPSErrors: true,
timeout: 120_000,
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
});

View File

@ -34,6 +34,7 @@ import {
} from "./components/repositories/main-repository-view/main-repository-view.component";
import { EnvVarsComponent } from "./components/admin/env-vars/env-vars.component";
import { MenusComponent } from "./components/menus/menus.component";
import { ImportLegacyComponent } from './components/admin/import-legacy/import-legacy.component';
import { OgDhcpSubnetsComponent } from "./components/ogdhcp/og-dhcp-subnets.component";
import { DhcpServersComponent } from './components/ogdhcp/dhcp-servers/dhcp-servers.component';
import { BootServersComponent } from './components/ogboot/boot-servers/boot-servers.component';
@ -47,6 +48,7 @@ import { HardwareComponent } from './components/hardware/hardware.component';
import { HardwareProfileComponent } from './components/hardware-profile/hardware-profile.component';
import { HardwareTypeComponent } from './components/hardware-type/hardware-type.component';
import { BootServerDashboardComponent } from './components/ogboot/boot-server-dashboard/boot-server-dashboard.component';
import { PluginsComponent } from './components/plugins/plugins.component';
const routes: Routes = [
{ path: '', redirectTo: 'auth/login', pathMatch: 'full' },
{
@ -55,6 +57,7 @@ const routes: Routes = [
{ path: 'users', component: UsersComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin'] } },
{ path: 'env-vars', component: EnvVarsComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin'] } },
{ path: 'roles', component: RolesComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin'] } },
{ path: 'import-legacy', component: ImportLegacyComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin'] } },
{ path: 'groups', component: GroupsComponent },
{ path: 'pxe-images', component: PXEimagesComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'pxe', component: PxeComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
@ -67,12 +70,12 @@ const routes: Routes = [
{ path: 'commands', component: CommandsComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'commands-groups', component: CommandsGroupsComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'commands-task', component: CommandsTaskComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'commands-logs', component: TaskLogsComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'commands-logs', component: TaskLogsComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin', 'ou-operator'] } },
{ path: 'calendars', component: CalendarComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'clients/deploy-image', component: DeployImageComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'clients/partition-assistant', component: PartitionAssistantComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'clients/run-script', component: RunScriptAssistantComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'clients/:id/create-image', component: CreateClientImageComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'clients/deploy-image', component: DeployImageComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin', 'ou-operator'] } },
{ path: 'clients/partition-assistant', component: PartitionAssistantComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin', 'ou-operator'] } },
{ path: 'clients/run-script', component: RunScriptAssistantComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin', 'ou-operator'] } },
{ path: 'clients/:id/create-image', component: CreateClientImageComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin', 'ou-operator'] } },
{ path: 'repositories', component: RepositoriesComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'repository/:id', component: MainRepositoryViewComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'software', component: SoftwareComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
@ -82,6 +85,7 @@ const routes: Routes = [
{ path: 'hardware', component: HardwareComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'hardware-profiles', component: HardwareProfileComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'hardware-types', component: HardwareTypeComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
{ path: 'plugins', component: PluginsComponent, canActivate: [roleGuard], data: { allowedRoles: ['super-admin', 'ou-admin'] } },
],
},
{

View File

@ -7,5 +7,6 @@
.content {
padding: 16px;
box-sizing: border-box;
}

View File

@ -148,6 +148,8 @@ import { CreateSubnetComponent } from "./components/ogdhcp/create-subnet/create-
import { AddClientsToSubnetComponent } from "./components/ogdhcp/add-clients-to-subnet/add-clients-to-subnet.component";
import { ShowClientsComponent } from './components/ogdhcp/show-clients/show-clients.component';
import { OperationResultDialogComponent } from './components/ogdhcp/operation-result-dialog/operation-result-dialog.component';
import { PluginsComponent } from './components/plugins/plugins.component';
import { PluginSettingsComponent } from './components/plugins/plugin-settings/plugin-settings.component';
import { ManageClientComponent } from './components/groups/shared/clients/manage-client/manage-client.component';
import { ConvertImageComponent } from './components/repositories/convert-image/convert-image.component';
import { DhcpServersComponent } from './components/ogdhcp/dhcp-servers/dhcp-servers.component';
@ -172,14 +174,17 @@ import { ShowTaskScheduleComponent } from './components/commands/commands-task/s
import { ShowTaskScriptComponent } from './components/commands/commands-task/show-task-script/show-task-script.component';
import { CreateTaskScriptComponent } from './components/commands/commands-task/create-task-script/create-task-script.component';
import { ViewParametersModalComponent } from './components/commands/commands-task/show-task-script/view-parameters-modal/view-parameters-modal.component';
import { ViewScriptModalComponent } from './components/commands/commands-task/show-task-script/view-script-modal/view-script-modal.component';
import { OutputDialogComponent } from './components/task-logs/output-dialog/output-dialog.component';
import { ClientTaskLogsComponent } from './components/task-logs/client-task-logs/client-task-logs.component';
import { BootSoPartitionComponent } from './components/commands/main-commands/execute-command/boot-so-partition/boot-so-partition.component';
import { RemoveCacheImageComponent } from './components/commands/main-commands/execute-command/remove-cache-image/remove-cache-image.component';
import { ChangeParentComponent } from './components/groups/shared/change-parent/change-parent.component';
import { SoftwareProfilePartitionComponent } from './components/commands/main-commands/execute-command/software-profile-partition/software-profile-partition.component';
import { ExecuteTaskNowComponent } from './components/commands/main-commands/execute-command/execute-task-now/execute-task-now.component';
import { ClientPendingTasksComponent } from './components/task-logs/client-pending-tasks/client-pending-tasks.component';
import { QueueConfirmationModalComponent } from './shared/queue-confirmation-modal/queue-confirmation-modal.component';
import { PluginCommandModalComponent } from './shared/plugin-command-modal/plugin-command-modal.component';
import { ModalOverlayComponent } from './shared/modal-overlay/modal-overlay.component';
import { ScrollToTopComponent } from './shared/scroll-to-top/scroll-to-top.component';
import { CreateTagModalComponent } from './components/repositories/show-git-images/create-tag-modal/create-tag-modal.component';
@ -201,11 +206,14 @@ import { OgcoreStatusComponent } from './components/global-status/ogcore-status/
import { GlobalOgbootStatusComponent } from './components/global-status/ogboot-status/ogboot-status.component';
import { OgrepositoryStatusComponent } from './components/global-status/ogrepository-status/ogrepository-status.component';
import { OgdhcpStatusComponent } from './components/global-status/ogdhcp-status/ogdhcp-status.component';
import { StatusLogsComponent } from './components/global-status/status-logs/status-logs.component';
export function HttpLoaderFactory(http: HttpClient) {
return new TranslateHttpLoader(http, './locale/', '.json');
}
import { ImportLegacyComponent } from './components/admin/import-legacy/import-legacy.component';
import { PageNotFoundComponent } from './shared/page-not-found/page-not-found.component';
import { OuSelectComponent } from './components/shared/ou-select/ou-select.component';
export function initializeApp(configService: ConfigService) {
return () => configService.loadConfig();
@ -232,6 +240,7 @@ registerLocaleData(localeEs, 'es-ES');
DeleteModalComponent,
DeleteResultsModalComponent,
QueueConfirmationModalComponent,
PluginCommandModalComponent,
ClassroomViewComponent,
ClientViewComponent,
ShowOrganizationalUnitComponent,
@ -314,12 +323,14 @@ registerLocaleData(localeEs, 'es-ES');
ShowTaskScriptComponent,
CreateTaskScriptComponent,
ViewParametersModalComponent,
ViewScriptModalComponent,
OutputDialogComponent,
ClientTaskLogsComponent,
BootSoPartitionComponent,
RemoveCacheImageComponent,
ChangeParentComponent,
SoftwareProfilePartitionComponent,
ExecuteTaskNowComponent,
ClientPendingTasksComponent,
ModalOverlayComponent,
ScrollToTopComponent,
@ -343,7 +354,12 @@ registerLocaleData(localeEs, 'es-ES');
GlobalOgbootStatusComponent,
OgrepositoryStatusComponent,
OgdhcpStatusComponent,
PageNotFoundComponent
StatusLogsComponent,
ImportLegacyComponent,
PageNotFoundComponent,
OuSelectComponent,
PluginsComponent,
PluginSettingsComponent
],
bootstrap: [AppComponent],
imports: [BrowserModule,

View File

@ -0,0 +1,146 @@
.main-content {
padding: 20px;
width: 100%;
box-sizing: border-box;
}
.radio-group {
display: flex;
flex-direction: column;
margin-bottom: 20px;
gap: 10px;
}
.form-container {
display: flex;
flex-direction: column;
gap: 15px;
max-width: 400px;
margin-top: 15px;
}
.help-text {
font-size: 14px;
color: #666;
margin-bottom: -5px;
}
.file-input-container {
display: flex;
align-items: center;
gap: 15px;
}
.file-name {
font-style: italic;
color: #666;
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 20px;
text-align: center;
width: 100%;
box-sizing: border-box;
}
.warning-text {
color: #f44336;
font-weight: bold;
margin-top: 10px;
}
.success-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
text-align: center;
gap: 15px;
}
.success-icon {
font-size: 64px;
height: 64px;
width: 64px;
color: #4caf50;
}
.error-banner {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
text-align: center;
background-color: #ffebee;
border-radius: 8px;
margin-bottom: 20px;
border: 1px solid #ef9a9a;
}
.error-banner h3 {
color: #c62828;
margin-top: 10px;
margin-bottom: 5px;
}
.error-banner p {
color: #c62828;
margin: 0;
}
.error-icon {
font-size: 48px;
height: 48px;
width: 48px;
}
.loader-box {
display: block;
text-align: center;
margin: 30px 0;
}
.loader-box mat-spinner {
margin: 0 auto;
}
.unified-log-box {
display: block;
width: 100%;
box-sizing: border-box;
text-align: left;
margin-top: 30px;
background-color: #1e1e1e;
color: #e0e0e0;
padding: 20px;
border-radius: 6px;
border: 1px solid #333;
}
.unified-log-box h4 {
margin-top: 0;
margin-bottom: 10px;
color: #4caf50;
border-bottom: 1px solid #333;
padding-bottom: 5px;
}
.unified-log-box pre {
white-space: pre-wrap;
word-wrap: break-word;
max-height: 400px;
overflow-y: auto;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 13px;
margin: 0;
}
.full-width-card {
width: 100% !important;
box-sizing: border-box;
}

View File

@ -0,0 +1,95 @@
<div class="main-content">
<div class="header-container">
<div class="header-container-title">
<h2>{{ 'importLegacyDbTitle' | translate }}</h2>
</div>
</div>
<mat-card class="full-width-card">
<mat-card-header>
<mat-card-title>{{ 'importLegacyDbDesc' | translate }}</mat-card-title>
</mat-card-header>
<mat-card-content>
@if (isLoading) {
<div class="loader-box">
<mat-spinner diameter="50"></mat-spinner>
<h3>{{ 'migrationInProgress' | translate }}</h3>
<p class="warning-text">{{ 'migrationWaitWarning' | translate }}</p>
</div>
} @else if (migrationStatus === 'success') {
<div class="success-container">
<mat-icon color="primary" class="success-icon">check_circle</mat-icon>
<h3>{{ 'migrationSuccess' | translate }}</h3>
<p>Los datos han sido migrados correctamente a la nueva versión.</p>
<button mat-raised-button color="primary" (click)="resetForm()">Realizar otra importación</button>
</div>
} @else {
@if (migrationStatus === 'error') {
<div class="error-banner">
<mat-icon color="warn" class="error-icon">error</mat-icon>
<h3>{{ 'migrationError' | translate }}</h3>
<p>Revisa el log en la parte inferior para más detalles o corrige los parámetros e inténtalo de nuevo.</p>
</div>
}
<mat-radio-group [(ngModel)]="importMode" class="radio-group">
<mat-radio-button value="db">{{ 'fromRemoteDb' | translate }}</mat-radio-button>
<mat-radio-button value="file">{{ 'fromSqlFile' | translate }}</mat-radio-button>
</mat-radio-group>
@if (importMode === 'db') {
<form [formGroup]="dbForm" (ngSubmit)="onSubmit()" class="form-container">
<mat-form-field appearance="outline">
<mat-label>{{ 'hostLabel' | translate }}</mat-label>
<input matInput formControlName="legacyHost" placeholder="192.168.1.100">
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ 'userLabel' | translate }}</mat-label>
<input matInput formControlName="legacyUser" placeholder="root">
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ 'passLabel' | translate }}</mat-label>
<input matInput formControlName="legacyPassword" type="password">
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ 'dbLabel' | translate }}</mat-label>
<input matInput formControlName="legacyDb" placeholder="opengnsys">
</mat-form-field>
<button mat-raised-button color="primary" type="submit" [disabled]="dbForm.invalid">
<mat-icon>cloud_download</mat-icon> {{ 'importButton' | translate }}
</button>
</form>
}
@if (importMode === 'file') {
<div class="form-container">
<p class="help-text">{{ 'uploadSqlOrZip' | translate }}</p>
<div class="file-input-container">
<button mat-stroked-button type="button" (click)="fileInput.click()">
<mat-icon>attach_file</mat-icon> {{ 'sqlFileLabel' | translate }}
</button>
<input hidden (change)="onFileSelected($event)" #fileInput type="file" accept=".sql,.zip">
@if (selectedFile) {
<span class="file-name">{{ selectedFile.name }}</span>
}
</div>
<button mat-raised-button color="primary" (click)="onSubmit()" [disabled]="!selectedFile" style="margin-top: 15px;">
<mat-icon>upload_file</mat-icon> {{ 'importButton' | translate }}
</button>
</div>
}
}
@if (outputLog) {
<div class="unified-log-box">
<h4>Consola en vivo:</h4>
<pre>{{ outputLog }}</pre>
</div>
}
</mat-card-content>
</mat-card>
</div>

View File

@ -0,0 +1,144 @@
import { Component, NgZone, ChangeDetectorRef } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { MatSnackBar } from '@angular/material/snack-bar';
import { TranslateService } from '@ngx-translate/core';
import { ConfigService } from '../../../services/config.service';
@Component({
selector: 'app-import-legacy',
templateUrl: './import-legacy.component.html',
styleUrls: ['./import-legacy.component.css']
})
export class ImportLegacyComponent {
importMode: 'file' | 'db' = 'db';
dbForm: FormGroup;
selectedFile: File | null = null;
isLoading = false;
outputLog = '';
migrationStatus: 'idle' | 'success' | 'error' = 'idle';
constructor(
private fb: FormBuilder,
private http: HttpClient,
private snackBar: MatSnackBar,
private translate: TranslateService,
private configService: ConfigService,
private ngZone: NgZone,
private cdr: ChangeDetectorRef
) {
this.dbForm = this.fb.group({
legacyHost: ['', Validators.required],
legacyUser: ['', Validators.required],
legacyPassword: [''],
legacyDb: ['', Validators.required]
});
}
ngOnInit() {
this.checkMigrationStatus();
}
checkMigrationStatus() {
this.http.get<any>(`${this.configService.apiUrl}/migration/import/status`).subscribe({
next: (response) => {
if (response.isRunning) {
this.isLoading = true;
this.outputLog = response.log || 'Recuperando estado de migración...';
this.connectToMercure();
}
},
error: (error) => {
console.error('Error checking migration status:', error);
}
});
}
private connectToMercure() {
const url = new URL(this.configService.mercureUrl);
url.searchParams.append('topic', '/migration');
const eventSource = new EventSource(url.toString());
eventSource.onmessage = (event) => {
this.ngZone.run(() => {
const data = JSON.parse(event.data);
if (data.type === 'migration_progress') {
this.outputLog = data.output;
this.cdr.detectChanges();
}
else if (data.type === 'migration_result') {
this.isLoading = false;
this.migrationStatus = data.status;
this.outputLog = data.output;
this.cdr.detectChanges();
if (data.status === 'success') {
this.snackBar.open(this.translate.instant('migrationSuccess'), 'OK', { duration: 5000 });
} else {
this.snackBar.open(this.translate.instant('migrationError'), 'OK', { duration: 5000 });
}
eventSource.close();
}
});
};
eventSource.onerror = (err) => {
console.error("Mercure connection error", err);
};
}
onFileSelected(event: any) {
const file = event.target.files[0];
if (file) {
this.selectedFile = file;
}
}
resetForm() {
this.migrationStatus = 'idle';
this.outputLog = '';
this.selectedFile = null;
this.dbForm.reset();
}
onSubmit() {
if (this.importMode === 'file' && !this.selectedFile) {
this.snackBar.open('Please select a file', 'Close', { duration: 3000 });
} else if ((this.importMode === 'db' && this.dbForm.valid) || (this.importMode === 'file' && this.selectedFile)) {
this.isLoading = true;
this.outputLog = '';
this.migrationStatus = 'idle';
let request;
if (this.importMode === 'file') {
const formData = new FormData();
formData.append('sql_file', this.selectedFile!);
request = this.http.post<any>(`${this.configService.apiUrl}/migration/import`, formData);
} else {
request = this.http.post<any>(`${this.configService.apiUrl}/migration/import`, this.dbForm.value);
}
request.subscribe({
next: (response) => {
if (response.status === 'started') {
this.outputLog = 'Proceso de migración en curso. Esperando respuesta del servidor en segundo plano...';
this.connectToMercure();
} else {
this.isLoading = false;
this.migrationStatus = 'success';
this.outputLog = response.output || response.message;
this.snackBar.open(this.translate.instant('migrationSuccess'), 'OK', { duration: 5000 });
}
},
error: (error) => {
this.isLoading = false;
this.migrationStatus = 'error';
const details = error.error?.error || error.error?.details || error.message || 'Unknown error';
this.outputLog = details;
this.snackBar.open(this.translate.instant('migrationError'), 'OK', { duration: 5000 });
}
});
}
}
}

View File

@ -15,30 +15,45 @@
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'labelRole' | translate }}</mat-label>
<mat-select formControlName="role" required>
<mat-option *ngFor="let group of userGroups" [value]="group['@id']">
{{ group.name }}
</mat-option>
@for (group of userGroups; track group['@id']) {
<mat-option [value]="group['@id']">
{{ group.name }}
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'labelOrganizationalUnit' | translate }}</mat-label>
<mat-select multiple formControlName="organizationalUnits">
<mat-option *ngFor="let unit of organizationalUnits" [value]="unit['@id']">
{{ unit.name }}
</mat-option>
</mat-select>
</mat-form-field>
<app-ou-select
class="full-width"
formControlName="organizationalUnits"
[multiple]="true"
[units]="organizationalUnits"
[label]="'labelOrganizationalUnit'">
</app-ou-select>
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'cardView' | translate }}</mat-label>
<mat-select formControlName="groupsView" required>
<mat-option *ngFor="let option of views" [value]="option.value">
{{ option.name }}
</mat-option>
@for (option of views; track option.value) {
<mat-option [value]="option.value">
{{ option.name }}
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'userLanguage' | translate }}</mat-label>
<mat-select formControlName="language">
@for (lang of availableLanguages; track lang.code) {
<mat-option [value]="lang.code">
{{ lang.label }}
</mat-option>
}
</mat-select>
<mat-hint>{{ 'userLanguageHint' | translate }}</mat-hint>
</mat-form-field>
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'clientsPerPage' | translate }}</mat-label>
<input matInput formControlName="clientsPerPage" type="number" min="1" [placeholder]="'default20' | translate">

View File

@ -7,6 +7,7 @@ import { ToastrService } from "ngx-toastr";
import { HttpClient } from "@angular/common/http";
import { DataService } from "../data.service";
import { ConfigService } from '@services/config.service';
import { DataService as GroupsDataService } from '../../../../groups/services/data.service';
interface UserGroup {
'@id': string;
@ -36,22 +37,28 @@ export class AddUserModalComponent implements OnInit {
{ value: 'list', name: 'Listado' },
];
readonly availableLanguages: { code: string; label: string }[];
constructor(
public dialogRef: MatDialogRef<AddUserModalComponent>,
@Inject(MAT_DIALOG_DATA) public data: any,
private http: HttpClient,
private fb: FormBuilder,
private dataService: DataService,
private groupsDataService: GroupsDataService,
private toastService: ToastrService,
private configService: ConfigService
) {
this.baseUrl = this.configService.apiUrl;
const langs = this.configService.languages;
this.availableLanguages = Object.keys(langs).map(code => ({ code, label: langs[code] }));
this.userForm = this.fb.group({
username: ['', Validators.required],
password: ['', Validators.required],
role: ['', Validators.required],
groupsView: ['card', Validators.required],
clientsPerPage: [null, [Validators.min(1)]],
language: ['es'],
organizationalUnits: [[]]
});
@ -66,7 +73,13 @@ export class AddUserModalComponent implements OnInit {
});
this.dataService.getOrganizationalUnits().subscribe((data) => {
this.organizationalUnits = data['hydra:member'].filter((item: any) => item.type === 'organizational-unit');
const allUnits = data['hydra:member'];
this.organizationalUnits = allUnits
.filter((item: any) => item.type === 'organizational-unit')
.map((unit: any) => ({
...unit,
path: this.groupsDataService.getOrganizationalUnitPath(unit, allUnits)
}));
});
if (this.data) {
@ -87,7 +100,8 @@ export class AddUserModalComponent implements OnInit {
role: response.userGroups.length > 0 ? response.userGroups[0]['@id'] : null,
organizationalUnits: organizationalUnitIds,
groupsView: response.groupsView,
clientsPerPage: response.clientsPerPage ?? null
clientsPerPage: response.clientsPerPage ?? null,
language: response.language ?? 'es'
});
this.userId = response['@id'];
@ -108,13 +122,22 @@ export class AddUserModalComponent implements OnInit {
onSubmit(): void {
if (this.userForm.valid) {
const mappedOUs = (this.userForm.value.organizationalUnits || []).map((ou: any) => {
if (typeof ou === 'number' || (typeof ou === 'string' && !ou.includes('/'))) {
const selectedUnit = this.organizationalUnits.find((u: any) => u.id === Number(ou));
return selectedUnit ? (selectedUnit['@id'] || `/organizational-units/${selectedUnit.uuid}`) : ou;
}
return ou;
});
const payload: any = {
username: this.userForm.value.username,
allowedOrganizationalUnits: this.userForm.value.organizationalUnits,
allowedOrganizationalUnits: mappedOUs,
enabled: true,
userGroups: [this.userForm.value.role],
groupsView: this.userForm.value.groupsView,
clientsPerPage: this.userForm.value.clientsPerPage || null
clientsPerPage: this.userForm.value.clientsPerPage || null,
language: this.userForm.value.language || null
};
if (!this.userId && this.userForm.value.password) {

View File

@ -3,116 +3,127 @@
</h2>
<mat-dialog-content class="dialog-content">
<mat-spinner class="loading-spinner" *ngIf="loading"></mat-spinner>
@if (loading) {
<mat-spinner class="loading-spinner"></mat-spinner>
}
<div class="task-form" *ngIf="!loading">
<mat-button-toggle-group *ngIf="data?.source === 'assistant'" [(ngModel)]="taskMode" class="task-mode-selection" name="taskMode" appearance="standard">
<mat-button-toggle value="create">
<mat-icon>add_task</mat-icon>
{{ 'labelCreateTask' | translate }}
</mat-button-toggle>
<mat-button-toggle value="add">
<mat-icon>playlist_add</mat-icon>
{{ 'addToExistingTask' | translate }}
</mat-button-toggle>
</mat-button-toggle-group>
</div>
<div *ngIf="taskMode === 'add'" class="select-task">
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'selectTask' | translate }}</mat-label>
<mat-select [(ngModel)]="selectedExistingTask" name="existingTask">
<mat-option *ngFor="let task of existingTasks" [value]="task">{{ task.name }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'executionOrder' | translate }}</mat-label>
<input
matInput
type="number"
[(ngModel)]="executionOrder"
name="executionOrder"
min="1"
[placeholder]="'enterOrder' | translate"
>
</mat-form-field>
</div>
<form *ngIf="taskMode === 'create' && taskForm && !loading" [formGroup]="taskForm" class="task-form">
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'labelRoleName' | translate }}</mat-label>
<input matInput formControlName="name" [placeholder]="'nameLabel' | translate">
<mat-error *ngIf="taskForm.get('name')?.invalid">{{ 'requiredFieldError' | translate }}</mat-error>
</mat-form-field>
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'labelNotes' | translate }}</mat-label>
<textarea matInput formControlName="notes" [placeholder]="'notesPlaceholder' | translate"></textarea>
</mat-form-field>
<mat-checkbox *ngIf="!editing" formControlName="scheduleAfterCreate" class="schedule-checkbox">
{{ 'scheduleAfterCreate' | translate }}
</mat-checkbox>
<mat-divider style="margin: 15px 0;"></mat-divider>
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'labelScope' | translate }}</mat-label>
<mat-select formControlName="scope" (selectionChange)="onScopeChange($event.value)"
[disabled]="data?.clients && data.clients.length >= 1">
<mat-option value="organizational-unit">{{ 'organizationalUnit' | translate }}</mat-option>
<mat-option value="classrooms-group">{{ 'scopeClassroomsGroup' | translate }}</mat-option>
<mat-option value="classroom">{{ 'classrooms' | translate }}</mat-option>
<mat-option value="clients-group">{{ 'clientGroupsTitle' | translate }}</mat-option>
<mat-option value="clients">{{ 'labelClients' | translate }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field *ngIf="taskForm.get('scope')?.value !== 'clients'" appearance="fill" class="full-width">
<mat-label>{{ 'labelOrganizationalUnit' | translate }}</mat-label>
<mat-select formControlName="organizationalUnit">
<mat-option *ngFor="let unit of availableOrganizationalUnits" [value]="unit['@id']">
<div class="unit-name">{{ unit.name }}</div>
<div style="font-size: smaller; color: gray;">{{ unit.path }}</div>
</mat-option>
</mat-select>
</mat-form-field>
<div *ngIf="taskForm.get('scope')?.value === 'clients'" class="clients-selection">
<h4>{{ 'selectedClients' | translate }}</h4>
<div *ngIf="data?.selectedClients && data.selectedClients.length > 0" class="pre-selected-info">
<mat-icon>info</mat-icon>
<span>{{ 'preSelectedInfo' | translate }}</span>
</div>
<div class="clients-list">
<div class="clients-grid">
<mat-card
*ngFor="let client of clients"
class="client-card"
[class.pre-selected]="isClientPreSelected(client)"
>
<mat-card-content>
<div class="client-info">
<div class="client-name">{{ client.name || client.hostname }}</div>
<div class="client-details">
<span class="client-ip">{{ client.ip }}</span>
<span class="client-status" [class]="'status-' + client.status">
{{ client.status }}
</span>
</div>
</div>
<mat-icon class="selected-icon">
check_circle
</mat-icon>
</mat-card-content>
</mat-card>
</div>
</div>
@if (!loading) {
<div class="task-form">
@if (data?.source === 'assistant') {
<mat-button-toggle-group [(ngModel)]="taskMode" class="task-mode-selection" name="taskMode" appearance="standard">
<mat-button-toggle value="create">
<mat-icon>add_task</mat-icon>
{{ 'labelCreateTask' | translate }}
</mat-button-toggle>
<mat-button-toggle value="add">
<mat-icon>playlist_add</mat-icon>
{{ 'addToExistingTask' | translate }}
</mat-button-toggle>
</mat-button-toggle-group>
}
</div>
</form>
}
@if (taskMode === 'add') {
<div class="select-task">
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'selectTask' | translate }}</mat-label>
<mat-select [(ngModel)]="selectedExistingTask" name="existingTask">
@for (task of existingTasks; track task) {
<mat-option [value]="task">{{ task.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'executionOrder' | translate }}</mat-label>
<input
matInput
type="number"
[(ngModel)]="executionOrder"
name="executionOrder"
min="1"
[placeholder]="'enterOrder' | translate"
>
</mat-form-field>
</div>
}
@if (taskMode === 'create' && taskForm && !loading) {
<form [formGroup]="taskForm" class="task-form">
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'labelRoleName' | translate }}</mat-label>
<input matInput formControlName="name" [placeholder]="'nameLabel' | translate">
@if (taskForm.get('name')?.invalid) {
<mat-error>{{ 'requiredFieldError' | translate }}</mat-error>
}
</mat-form-field>
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'labelNotes' | translate }}</mat-label>
<textarea matInput formControlName="notes" [placeholder]="'notesPlaceholder' | translate"></textarea>
</mat-form-field>
@if (!editing) {
<mat-checkbox formControlName="scheduleAfterCreate" class="schedule-checkbox">
{{ 'scheduleAfterCreate' | translate }}
</mat-checkbox>
}
<mat-divider style="margin: 15px 0;"></mat-divider>
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'labelScope' | translate }}</mat-label>
<mat-select formControlName="scope" (selectionChange)="onScopeChange($event.value)"
[disabled]="data?.clients && data.clients.length >= 1">
<mat-option value="organizational-unit">{{ 'organizationalUnit' | translate }}</mat-option>
<mat-option value="classrooms-group">{{ 'scopeClassroomsGroup' | translate }}</mat-option>
<mat-option value="classroom">{{ 'classrooms' | translate }}</mat-option>
<mat-option value="clients-group">{{ 'clientGroupsTitle' | translate }}</mat-option>
<mat-option value="clients">{{ 'labelClients' | translate }}</mat-option>
</mat-select>
</mat-form-field>
@if (taskForm.get('scope')?.value !== 'clients') {
<app-ou-select
class="full-width"
formControlName="organizationalUnit"
[units]="availableOrganizationalUnits"
[label]="'labelOrganizationalUnit'">
</app-ou-select>
}
@if (taskForm.get('scope')?.value === 'clients') {
<div class="clients-selection">
<h4>{{ 'selectedClients' | translate }}</h4>
@if (data?.selectedClients && data.selectedClients.length > 0) {
<div class="pre-selected-info">
<mat-icon>info</mat-icon>
<span>{{ 'preSelectedInfo' | translate }}</span>
</div>
}
<div class="clients-list">
<div class="clients-grid">
@for (client of clients; track client) {
<mat-card
class="client-card"
[class.pre-selected]="isClientPreSelected(client)"
>
<mat-card-content>
<div class="client-info">
<div class="client-name">{{ client.name || client.hostname }}</div>
<div class="client-details">
<span class="client-ip">{{ client.ip }}</span>
<span class="client-status" [class]="'status-' + client.status">
{{ client.status }}
</span>
</div>
</div>
<mat-icon class="selected-icon">
check_circle
</mat-icon>
</mat-card-content>
</mat-card>
}
</div>
</div>
</div>
}
</form>
}
</mat-dialog-content>
<mat-dialog-actions class="action-container">
@ -120,7 +131,7 @@
<button
class="submit-button"
(click)="taskMode === 'create' ? saveTask() : addToExistingTask()"
>
>
{{ 'labelSaveButton' | translate }}
</button>
</mat-dialog-actions>

View File

@ -10,6 +10,7 @@ import {of} from "rxjs";
import {startWith, switchMap} from "rxjs/operators";
import {CreateTaskScheduleComponent} from "../create-task-schedule/create-task-schedule.component";
import {ShowTaskScriptComponent} from "../show-task-script/show-task-script.component";
import { DataService as GroupsDataService } from "../../../groups/services/data.service";
@Component({
selector: 'app-create-task',
@ -42,7 +43,8 @@ export class CreateTaskComponent implements OnInit {
private toastr: ToastrService,
private dialog: MatDialog,
public dialogRef: MatDialogRef<CreateTaskComponent>,
@Inject(MAT_DIALOG_DATA) public data: any
@Inject(MAT_DIALOG_DATA) public data: any,
private dataService: GroupsDataService
) {
this.baseUrl = this.configService.apiUrl;
this.apiUrl = `${this.baseUrl}/command-tasks`;
@ -214,7 +216,10 @@ export class CreateTaskComponent implements OnInit {
return new Promise((resolve, reject) => {
this.http.get<any>(`${this.baseUrl}/organizational-units?page=1&itemsPerPage=100`).subscribe(
(data) => {
this.allOrganizationalUnits = data['hydra:member'];
this.allOrganizationalUnits = data['hydra:member'].map((unit: any) => ({
...unit,
path: this.dataService.getOrganizationalUnitPath(unit, data['hydra:member'])
}));
this.availableOrganizationalUnits = [...this.allOrganizationalUnits];
resolve();
},
@ -276,7 +281,12 @@ export class CreateTaskComponent implements OnInit {
payload.clients = this.selectedClients.map(client => client['@id'] ? client['@id'] : client.uuid);
payload.organizationalUnit = null;
} else {
payload.organizationalUnit = formData.organizationalUnit;
let ou = formData.organizationalUnit;
if (typeof ou === 'number' || (typeof ou === 'string' && !ou.includes('/'))) {
const selectedUnit = this.availableOrganizationalUnits.find((u: any) => u.id === Number(ou));
ou = selectedUnit ? (selectedUnit['@id'] || `/organizational-units/${selectedUnit.uuid}`) : ou;
}
payload.organizationalUnit = ou;
}
if (this.editing) {

View File

@ -321,18 +321,21 @@ mat-spinner {
color: #666;
}
/* Contenido del script en celda */
.script-content-cell {
background-color: #f5f5f5;
padding: 8px 12px;
border: 1px solid #e0e0e0;
border-radius: 4px;
font-family: 'Monaco', 'Menlo', monospace;
white-space: pre-wrap;
font-size: 12px;
max-width: 300px;
/* Vista previa del script en celda (una sola línea, altura de fila fija) */
.script-preview-cell {
display: flex;
align-items: center;
gap: 4px;
max-width: 260px;
}
.script-preview-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 12px;
color: #555;
}
.no-content-hint {

View File

@ -50,8 +50,11 @@
<ng-container matColumnDef="content">
<th mat-header-cell *matHeaderCellDef>{{ 'labelScript' | translate }}</th>
<td mat-cell *matCellDef="let item">
<div *ngIf="item.content" class="script-content-cell">
<code>{{ item.content }}</code>
<div *ngIf="item.content" class="script-preview-cell">
<span class="script-preview-text">{{ item.content }}</span>
<button mat-icon-button (click)="openScriptModal(item.content)" [matTooltip]="'viewScript' | translate">
<mat-icon>visibility</mat-icon>
</button>
</div>
<span *ngIf="!item.content" class="no-content-hint">
({{ 'viewParameters' | translate }})

View File

@ -10,6 +10,7 @@ import { CreateTaskScheduleComponent } from "../create-task-schedule/create-task
import { CreateTaskScriptComponent } from "../create-task-script/create-task-script.component";
import { DeleteModalComponent } from "../../../../shared/delete_modal/delete-modal/delete-modal.component";
import { ViewParametersModalComponent } from "./view-parameters-modal/view-parameters-modal.component";
import { ViewScriptModalComponent } from "./view-script-modal/view-script-modal.component";
@Component({
selector: 'app-show-task-script',
@ -119,6 +120,13 @@ export class ShowTaskScriptComponent implements OnInit {
});
}
openScriptModal(content: string): void {
this.dialog.open(ViewScriptModalComponent, {
width: '700px',
data: content
});
}
createNewScript(): void {
const dialogRef = this.dialog.open(CreateTaskScriptComponent, {
width: '800px',

View File

@ -0,0 +1,12 @@
.script-full-content {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 13px;
white-space: pre-wrap;
word-break: break-word;
background: #f5f5f5;
border: 1px solid #e0e0e0;
border-radius: 4px;
padding: 12px;
max-height: 60vh;
overflow-y: auto;
}

View File

@ -0,0 +1,7 @@
<h2 mat-dialog-title>{{ 'labelScript' | translate }}</h2>
<mat-dialog-content>
<pre class="script-full-content">{{ data }}</pre>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button mat-button (click)="close()">{{ 'buttonClose' | translate }}</button>
</mat-dialog-actions>

View File

@ -0,0 +1,18 @@
import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
@Component({
selector: 'app-view-script-modal',
templateUrl: './view-script-modal.component.html',
styleUrl: './view-script-modal.component.css'
})
export class ViewScriptModalComponent {
constructor(
public dialogRef: MatDialogRef<ViewScriptModalComponent>,
@Inject(MAT_DIALOG_DATA) public data: any
) {}
close(): void {
this.dialogRef.close();
}
}

View File

@ -6,7 +6,7 @@
<h2 joyrideStep="titleStep" text="{{ 'titleStepText' | translate }}">{{ 'CommandsTitle' | translate }}</h2>
</div>
<div class="command-button-row" joyrideStep="addCommandStep" text="{{ 'addCommandStepText' | translate }}">
<button class="action-button" (click)="openCreateCommandModal()">{{ 'addCommand' | translate }}</button>
<button *ngIf="auth.isSuperAdmin" class="action-button" (click)="openCreateCommandModal()">{{ 'addCommand' | translate }}</button>
</div>
</div>
@ -44,8 +44,8 @@
<th mat-header-cell *matHeaderCellDef style="text-align: center;">{{ 'actions' | translate }}</th>
<td mat-cell *matCellDef="let command" style="text-align: center;" joyrideStep="actionsStep" text="{{ 'actionsStepText' | translate }}">
<button mat-icon-button color="info" (click)="viewDetails($event, command)"><mat-icon>visibility</mat-icon></button>
<button mat-icon-button color="primary" [disabled]="command.readOnly" (click)="editCommand($event, command)"><mat-icon>edit</mat-icon></button>
<button mat-icon-button color="warn" [disabled]="command.readOnly" (click)="deleteCommand($event, command)"><mat-icon>delete</mat-icon></button>
<button *ngIf="auth.isSuperAdmin" mat-icon-button color="primary" [disabled]="command.readOnly" (click)="editCommand($event, command)"><mat-icon>edit</mat-icon></button>
<button *ngIf="auth.isSuperAdmin" mat-icon-button color="warn" [disabled]="command.readOnly" (click)="deleteCommand($event, command)"><mat-icon>delete</mat-icon></button>
</td>
</ng-container>

View File

@ -10,6 +10,7 @@ import { DeleteModalComponent } from '../../../shared/delete_modal/delete-modal/
import { MatTableDataSource } from '@angular/material/table';
import { DatePipe } from '@angular/common';
import { ConfigService } from '@services/config.service';
import { AuthService } from '@services/auth.service';
import { JoyrideService } from 'ngx-joyride';
@Component({
@ -19,6 +20,7 @@ import { JoyrideService } from 'ngx-joyride';
})
export class CommandsComponent implements OnInit {
public translate = inject(TranslateService);
public auth = inject(AuthService);
baseUrl: string;
private apiUrl: string;
dataSource = new MatTableDataSource<any>();

View File

@ -99,6 +99,10 @@
box-shadow: 0px 0px 0px rgba(0,0,0,0.2);
}
.section-divider {
margin-top: 20px;
}
@media (max-width: 600px) {
.form-field {
width: 100%;

View File

@ -1,95 +1,70 @@
<h2 mat-dialog-title> {{ 'selectPartitionToBootOs' | translate }}</h2>
<mat-dialog-content class="dialog-content">
<mat-spinner class="loading-spinner" *ngIf="loading"></mat-spinner>
@if (loading) {
<mat-spinner class="loading-spinner"></mat-spinner>
} @else {
<div class="select-container">
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title> {{ 'labelClients' | translate }}</mat-panel-title>
<mat-panel-description>
{{ 'clientListBootOs' | translate }}<mat-icon>desktop_windows</mat-icon>
</mat-panel-description>
</mat-expansion-panel-header>
<div *ngIf="!loading" class="select-container">
<mat-expansion-panel>
<mat-expansion-panel-header>
<mat-panel-title> {{ 'labelClients' | translate }}</mat-panel-title>
<mat-panel-description>
{{ 'clientListBootOs' | translate }}<mat-icon>desktop_windows</mat-icon>
</mat-panel-description>
</mat-expansion-panel-header>
<div class="button-row">
<button class="action-button" (click)="toggleSelectAll()">
{{ allSelected ? 'Desmarcar todos' : 'Marcar todos' }}
</button>
</div>
<div class="clients-grid">
<div *ngFor="let client of data.clients" class="client-item">
<div class="client-card"
(click)="toggleClientSelection(client)"
[ngClass]="{'selected-client': client.selected}" >
<img
[src]="'assets/images/computer_' + client.status + '.svg'"
alt="Client Icon"
class="client-image" />
<div class="client-details">
<span class="client-name">{{ client.name | slice:0:20 }}</span>
<span class="client-ip">{{ client.ip }}</span>
<span class="client-ip">{{ client.mac }}</span>
</div>
<mat-divider></mat-divider>
<mat-radio-group [(ngModel)]="selectedModelClient" (change)="loadPartitions(selectedModelClient)">
<mat-radio-button [value]="client"
color="primary"
[disabled]="!client.selected"
(click)="$event.stopPropagation()">
{{ 'labelModel' | translate }}</mat-radio-button>
</mat-radio-group>
</div>
<div class="button-row">
<button class="action-button" (click)="toggleSelectAll()">
{{ allSelected ? 'Desmarcar todos' : 'Marcar todos' }}
</button>
</div>
</div>
</mat-expansion-panel>
</div>
<div class="clients-grid">
@for (client of data.clients; track client.uuid) {
<div class="client-item">
<div class="client-card"
(click)="toggleClientSelection(client)"
[ngClass]="{'selected-client': client.selected}" >
<mat-divider *ngIf="!loading" style="margin-top: 20px;"></mat-divider>
<img
[src]="'assets/images/computer_' + client.status + '.svg'"
alt="Client Icon"
class="client-image" />
<div *ngIf="!loading" class="partition-table-container">
<table mat-table [dataSource]="dataSource" class="mat-elevation-z8">
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef i18n="@@columnActions" style="text-align: start">{{ 'buttonSelectPartition' | translate }}</th>
<td mat-cell *matCellDef="let row">
<mat-radio-group
[(ngModel)]="selectedPartition"
[disabled]="!row.operativeSystem"
>
<mat-radio-button [value]="row">
</mat-radio-button>
</mat-radio-group>
</td>
</ng-container>
<div class="client-details">
<span class="client-name">{{ client.name | slice:0:20 }}</span>
<span class="client-ip">{{ client.ip }}</span>
<span class="client-ip">{{ client.mac }}</span>
</div>
<mat-divider></mat-divider>
<ng-container *ngFor="let column of columns" [matColumnDef]="column.columnDef">
<th mat-header-cell *matHeaderCellDef> {{ column.header | translate }} </th>
<td mat-cell *matCellDef="let image">
<ng-container *ngIf="column.columnDef !== 'size'">
{{ column.cell(image) }}
</ng-container>
<ng-container *ngIf="column.columnDef === 'size'">
<div style="display: flex; flex-direction: column;">
<span> {{ image.size }} MB</span>
<span style="font-size: 0.75rem; color: gray;">{{ image.size / 1024 }} GB</span>
<mat-radio-group [(ngModel)]="selectedModelClient" (change)="loadPartitions(selectedModelClient)">
<mat-radio-button [value]="client"
color="primary"
[disabled]="!client.selected"
(click)="$event.stopPropagation()">
{{ 'labelModel' | translate }}</mat-radio-button>
</mat-radio-group>
</div>
</div>
</ng-container>
}
</div>
</td>
</ng-container>
</mat-expansion-panel>
</div>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
</div>
<mat-divider class="section-divider"></mat-divider>
<div class="partition-table-container">
<app-disk-partition-selector
[(selectedPartition)]="selectedPartition"
[partitions]="dataSource.data"
[columns]="columns"
[displayedColumns]="displayedColumns"
[disableCondition]="isPartitionDisabled">
</app-disk-partition-selector>
</div>
}
</mat-dialog-content>
<div mat-dialog-actions class="action-container">

View File

@ -20,6 +20,7 @@ export class BootSoPartitionComponent implements OnInit {
clientId: string | null = null;
selectedClients: any[] = [];
selectedModelClient: any = null;
partitions: any[] = [];
filteredPartitions: any[] = [];
allSelected: boolean = false;
clientData: any[] = [];
@ -59,6 +60,10 @@ export class BootSoPartitionComponent implements OnInit {
displayedColumns = ['select', ...this.columns.map(column => column.columnDef)];
isPartitionDisabled = (partition: any): boolean => {
return !partition.operativeSystem;
};
constructor(
@Inject(MAT_DIALOG_DATA) public data: { clients: any },
private dialogRef: MatDialogRef<BootSoPartitionComponent>,
@ -89,9 +94,10 @@ export class BootSoPartitionComponent implements OnInit {
this.http.get(url).subscribe(
(response: any) => {
if (response.partitions) {
this.dataSource.data = response.partitions.filter((partition: any) => {
return partition.partitionNumber !== 0;
});
this.partitions = Array.isArray(response.partitions)
? response.partitions.filter((p: any) => p.partitionCode !== 'CACHE' && p.partitionCode !== 'EMPTY')
: [];
this.dataSource.data = this.partitions;
}
},
(error) => {
@ -107,7 +113,7 @@ export class BootSoPartitionComponent implements OnInit {
updateSelectedClients() {
this.selectedClients = this.data.clients.filter(
(client: { selected: boolean; state: string }) => client.selected && client.state === "og-live"
(client: { selected: boolean }) => client.selected
);
if (!this.selectedClients.includes(this.selectedModelClient)) {
@ -118,11 +124,10 @@ export class BootSoPartitionComponent implements OnInit {
toggleSelectAll() {
this.allSelected = !this.allSelected;
this.data.clients.forEach((client: { selected: boolean; status: string }) => {
if (client.status === "og-live") {
client.selected = this.allSelected;
}
this.data.clients.forEach((client: { selected: boolean }) => {
client.selected = this.allSelected;
});
this.updateSelectedClients();
}
close() {

View File

@ -1,24 +1,53 @@
<ng-container [ngSwitch]="buttonType">
<button *ngSwitchCase="'icon'" mat-icon-button color="primary" [matMenuTriggerFor]="commandMenu"
[disabled]="disabled">
<mat-icon>{{ icon }}</mat-icon>
</button>
<button class="action-button" [disabled]="clientData.length === 0 || disabled" *ngSwitchCase="'text'"
[matMenuTriggerFor]="commandMenu">
{{ buttonText }}
</button>
<button mat-menu-item *ngSwitchCase="'menu-item'" [matMenuTriggerFor]="commandMenu" [disabled]="disabled">
<mat-icon>{{ icon }}</mat-icon>
<span>{{ buttonText }}</span>
</button>
</ng-container>
@switch (buttonType) {
@case ('icon') {
<button mat-icon-button color="primary" [matMenuTriggerFor]="commandMenu" [disabled]="disabled">
<mat-icon>{{ icon }}</mat-icon>
</button>
}
@case ('text') {
<button class="action-button" [disabled]="clientData.length === 0 || disabled" [matMenuTriggerFor]="commandMenu">
{{ buttonText }}
</button>
}
@case ('menu-item') {
<button mat-menu-item [matMenuTriggerFor]="commandMenu" [disabled]="disabled">
<mat-icon>{{ icon }}</mat-icon>
<span>{{ buttonText }}</span>
</button>
}
}
<mat-menu #commandMenu="matMenu">
<button mat-menu-item [disabled]="command.disabled
|| (command.slug === 'create-image' && clientData.length > 1)" *ngFor="let command of arrayCommands"
(click)="onCommandSelect(command.slug)">
{{ command.translationKey | translate }}
</button>
@for (command of arrayCommands; track command.slug) {
<button mat-menu-item [disabled]="command.disabled || (command.slug === 'create-image' && clientData.length > 1)"
(click)="onCommandSelect(command.slug)">
@if (command.icon) {
<mat-icon>{{ command.icon }}</mat-icon>
}
<span>{{ command.translationKey ? (command.translationKey | translate) : (command.label || command.slug) }}</span>
</button>
}
@if (pluginGroups.length > 0) {
<mat-divider></mat-divider>
@for (group of pluginGroups; track group.plugin.name) {
<button mat-menu-item [matMenuTriggerFor]="pluginSubMenu">
@if (group.plugin.icon) {
<mat-icon>{{ group.plugin.icon }}</mat-icon>
}
<span>{{ group.plugin.titleKey ? (group.plugin.titleKey | translate) : group.plugin.title }}</span>
</button>
<mat-menu #pluginSubMenu="matMenu">
@for (cmd of group.commands; track cmd.slug) {
<button mat-menu-item [disabled]="cmd.disabled" (click)="onCommandSelect(cmd.slug)">
@if (cmd.icon) {
<mat-icon>{{ cmd.icon }}</mat-icon>
}
<span>{{ (cmd.translationKey || cmd.labelKey) ? ((cmd.translationKey || cmd.labelKey) | translate) : cmd.label }}</span>
</button>
}
</mat-menu>
}
}
</mat-menu>

View File

@ -10,6 +10,10 @@ import { MatDialog } from "@angular/material/dialog";
import { RemoveCacheImageComponent } from "./remove-cache-image/remove-cache-image.component";
import { AuthService } from '@services/auth.service';
import {SoftwareProfilePartitionComponent} from "./software-profile-partition/software-profile-partition.component";
import { QueueConfirmationModalComponent } from '../../../../shared/queue-confirmation-modal/queue-confirmation-modal.component';
import { PluginCommandModalComponent } from '../../../../shared/plugin-command-modal/plugin-command-modal.component';
import { ExecuteTaskNowComponent } from './execute-task-now/execute-task-now.component';
import { PluginService, PluginCommand } from '@services/plugin.service';
@Component({
selector: 'app-execute-command',
@ -19,6 +23,8 @@ import {SoftwareProfilePartitionComponent} from "./software-profile-partition/so
export class ExecuteCommandComponent implements OnInit {
public translate = inject(TranslateService);
@Input() runScriptContext: any = null;
@Input() organizationalUnit: any = null;
@Input() showOnlyDirectClients: boolean = false;
@Input() clientState: string = 'off';
@Input() clientData: any[] = [];
@Input() buttonType: 'icon' | 'text' | 'menu-item' = 'icon';
@ -27,12 +33,13 @@ export class ExecuteCommandComponent implements OnInit {
@Input() disabled: boolean = false;
baseUrl: string;
loading: boolean = true;
isFetchingOuClients: boolean = false;
arrayCommands: any[] = [
{ translationKey: 'executeCommands.powerOn', slug: 'power-on', disabled: false },
{ translationKey: 'executeCommands.powerOff', slug: 'power-off', disabled: false },
{ translationKey: 'executeCommands.reboot', slug: 'reboot', disabled: false },
{ translationKey: 'executeCommands.login', slug: 'login', disabled: true },
{ translationKey: 'executeCommands.login', slug: 'login', disabled: false },
{ translationKey: 'executeCommands.createImage', slug: 'create-image', disabled: false },
{ translationKey: 'executeCommands.deployImage', slug: 'deploy-image', disabled: false },
{ translationKey: 'executeCommands.deleteImageCache', slug: 'remove-cache-image', disabled: false },
@ -42,6 +49,7 @@ export class ExecuteCommandComponent implements OnInit {
{ translationKey: 'executeCommands.runScript', slug: 'run-script', disabled: false },
{ translationKey: 'executeCommands.refresh', slug: 'refresh', disabled: false },
{ translationKey: 'executeCommands.deleteLease', slug: 'delete-lease', disabled: false },
{ translationKey: 'executeCommands.executeTask', slug: 'execute-task', disabled: false },
];
client: any = {};
@ -53,15 +61,28 @@ export class ExecuteCommandComponent implements OnInit {
private toastService: ToastrService,
public auth: AuthService,
private dialog: MatDialog,
public pluginService: PluginService,
) {
this.baseUrl = this.configService.apiUrl;
}
pluginGroups: any[] = [];
ngOnInit(): void {
this.clientData = this.clientData || [];
const allowed = this.getAllowedCommandsByRole();
this.arrayCommands = this.arrayCommands.filter(c => allowed.includes(c.slug));
// Punto de anclaje reactivo para grupos de comandos aportados por plugins
this.pluginGroups = this.pluginService.getPluginGroups();
this.pluginService.plugins$.subscribe(() => {
this.pluginGroups = this.pluginService.getPluginGroups();
this.updateCommandStates();
});
this.pluginService.statusUpdates$.subscribe(() => {
this.updateCommandStates();
});
this.updateCommandStates();
}
@ -80,13 +101,15 @@ export class ExecuteCommandComponent implements OnInit {
'power-off',
'reboot',
'login',
'create-image',
'deploy-image',
'software-inventory',
'hardware-inventory',
'remove-cache-image',
'partition',
'refresh',
'delete-lease'
'delete-lease',
'execute-task'
],
'ou-minimal': [
'power-on',
@ -108,40 +131,127 @@ export class ExecuteCommandComponent implements OnInit {
states = [this.clientState];
}
const allOffOrDisconnected = states.every(state => state === 'off' || state === 'disconnected');
const allSameState = states.every(state => state === states[0]);
const hasClients = this.clientData.length > 0 || !!this.clientState;
const multipleClients = this.clientData.length > 1;
const hasOgLive = states.some(state => state === 'og-live');
// Acciones que admiten encolado o son independientes del estado del cliente
const queueableOrStateAgnostic = [
'power-on',
'power-off',
'reboot',
'login',
'deploy-image',
'partition',
'run-script',
'execute-task',
'delete-lease'
];
this.arrayCommands = this.arrayCommands.map(command => {
if (allOffOrDisconnected) {
command.disabled = command.slug !== 'power-on' && command.slug !== 'create-image';
} else if (allSameState) {
if (states[0] === 'off' || states[0] === 'disconnected') {
command.disabled = !['power-on', 'create-image'].includes(command.slug);
} else {
command.disabled = !['power-off', 'reboot', 'login', 'create-image', 'deploy-image', 'remove-cache-image', 'partition', 'run-script', 'software-inventory', 'hardware-inventory', 'refresh'].includes(command.slug);
}
} else {
if (command.slug === 'software-inventory') {
command.disabled = multipleClients;
} else if (
['power-on', 'power-off', 'reboot', 'login', 'deploy-image', 'partition', 'remove-cache-image', 'run-script', 'create-image'].includes(command.slug)
) {
command.disabled = false;
} else {
command.disabled = true;
}
if (!hasClients) {
command.disabled = true;
return command;
}
if (command.slug === 'refresh') {
command.disabled = !states.every(state => state === 'og-live');
// Restricciones lógicas estrictas
if (command.slug === 'create-image') {
command.disabled = multipleClients || !hasOgLive;
} else if (command.slug === 'hardware-inventory' || command.slug === 'software-inventory') {
command.disabled = multipleClients || !hasOgLive;
} else if (command.slug === 'remove-cache-image') {
command.disabled = !hasOgLive;
} else if (command.slug === 'refresh') {
command.disabled = !hasOgLive;
} else if (queueableOrStateAgnostic.includes(command.slug)) {
command.disabled = false;
} else {
command.disabled = false;
}
return command;
});
if (this.pluginGroups && this.pluginGroups.length > 0) {
const clientsToCheck = this.clientData.length > 0
? this.clientData
: (this.clientState ? [{ status: this.clientState }] : []);
for (const group of this.pluginGroups) {
for (const cmd of group.commands) {
cmd.disabled = !hasClients || !this.pluginService.isCommandSupportedForClients(cmd, clientsToCheck, group.plugin.name);
// Adaptar dinámicamente la etiqueta de activar/desactivar en ogaquarium si hay un solo cliente seleccionado
if (cmd.slug === 'ogaquarium-toggle-status' && this.clientData.length === 1) {
const client = this.clientData[0];
const idKey = client.id ? String(client.id) : null;
const uuidKey = client.uuid || client.name;
const currentStatus = (idKey ? this.pluginService.getPluginStatus(group.plugin.name, idKey) : null)
|| (uuidKey ? this.pluginService.getPluginStatus(group.plugin.name, uuidKey) : null)
|| 'DISABLED';
if (currentStatus === 'DISABLED') {
cmd.label = this.translate.instant('ogaquarium.commands.activate') || 'Activar puesto';
} else {
cmd.label = this.translate.instant('ogaquarium.commands.deactivate') || 'Desactivar puesto';
}
cmd.labelKey = undefined;
}
}
}
}
}
onCommandSelect(action: any): void {
if (this.organizationalUnit && this.organizationalUnit.id) {
this.fetchClientsForOuAndExecute(action);
return;
}
this.executeAction(action);
}
private fetchClientsForOuAndExecute(action: any): void {
if (this.isFetchingOuClients) {
return;
}
this.isFetchingOuClients = true;
const unitId = this.organizationalUnit.id;
this.http.get<any>(`${this.baseUrl}/clients?organizationalUnit.id=${unitId}&page=1&itemsPerPage=10000`).subscribe({
next: (response) => {
this.isFetchingOuClients = false;
let allClients: any[] = response['hydra:member'] || [];
if (this.showOnlyDirectClients && this.organizationalUnit['@id']) {
allClients = allClients.filter((client: any) =>
client.organizationalUnit?.['@id'] === this.organizationalUnit['@id']
);
}
if (allClients.length === 0) {
this.toastService.warning(this.translate.instant('thisOrganizationalUnitDoesNotHave') || 'Esta unidad organizativa no tiene clientes.');
return;
}
this.clientData = allClients;
this.executeAction(action);
},
error: (error) => {
this.isFetchingOuClients = false;
this.toastService.error(this.translate.instant('errorLoadingClients1') || 'Error al cargar los clientes');
}
});
}
private executeAction(action: any): void {
// Punto de anclaje para ejecución de comandos aportados por plugins
const pluginCmd = this.pluginService.findCommand(action);
if (pluginCmd) {
this.executePluginCommand(pluginCmd);
return;
}
if (action === 'partition') {
this.openPartitionAssistant();
}
@ -193,19 +303,37 @@ export class ExecuteCommandComponent implements OnInit {
if (action === 'delete-lease') {
this.deleteClientLease();
}
if (action === 'execute-task') {
this.openExecuteTaskModal();
}
}
openExecuteTaskModal(): void {
this.dialog.open(ExecuteTaskNowComponent, {
width: '500px',
height: 'auto',
data: { clients: this.clientData }
});
}
rebootClient(): void {
this.http.post(`${this.baseUrl}/clients/server/reboot`, {
clients: this.clientData.map((client: any) => client['@id'])
}).subscribe(
response => {
this.toastService.success(this.translate.instant('clientUpdatedSuccessfully'));
},
error => {
this.toastService.error(error.error['hydra:description'] || 'Error de conexión con el cliente');
}
);
const dialogRef = this.dialog.open(QueueConfirmationModalComponent, {
width: '400px',
data: { clients: this.clientData }
});
dialogRef.afterClosed().subscribe((queue: boolean | undefined) => {
if (queue === undefined) return;
this.http.post(`${this.baseUrl}/clients/server/reboot`, {
clients: this.clientData.map((client: any) => client['@id']),
queue
}).subscribe(
() => { this.toastService.success(this.translate.instant('clientUpdatedSuccessfully')); },
(error: any) => { this.toastService.error(error.error['hydra:description'] || 'Error de conexión con el cliente'); }
);
});
}
refreshClient(): void {
@ -332,16 +460,22 @@ export class ExecuteCommandComponent implements OnInit {
}
powerOffClient(): void {
this.http.post(`${this.baseUrl}/clients/server/power-off`, {
clients: this.clientData.map((client: any) => client['@id'])
}).subscribe(
response => {
this.toastService.success(this.translate.instant('shutdownRequestSentCorrectly'));
},
error => {
this.toastService.error(error.error['hydra:description'] || 'Error de conexión con el cliente');
}
);
const dialogRef = this.dialog.open(QueueConfirmationModalComponent, {
width: '400px',
data: { clients: this.clientData }
});
dialogRef.afterClosed().subscribe((queue: boolean | undefined) => {
if (queue === undefined) return;
this.http.post(`${this.baseUrl}/clients/server/power-off`, {
clients: this.clientData.map((client: any) => client['@id']),
queue
}).subscribe(
() => { this.toastService.success(this.translate.instant('shutdownRequestSentCorrectly')); },
(error: any) => { this.toastService.error(error.error['hydra:description'] || 'Error de conexión con el cliente'); }
);
});
}
openPartitionAssistant(): void {
@ -426,4 +560,102 @@ export class ExecuteCommandComponent implements OnInit {
}
});
}
private executePluginCommand(pluginCmd: PluginCommand): void {
if (!this.clientData || this.clientData.length === 0) {
this.toastService.warning('No hay ningún equipo seleccionado para ejecutar esta acción.', pluginCmd.label);
return;
}
const hasParams = !!(pluginCmd.params && pluginCmd.params.length > 0);
const hasConfirm = !!(pluginCmd.confirm || pluginCmd.confirmKey);
if (hasParams || hasConfirm) {
const dialogRef = this.dialog.open(PluginCommandModalComponent, {
width: '450px',
data: {
command: pluginCmd,
clients: this.clientData
}
});
dialogRef.afterClosed().subscribe((result: any) => {
if (!result || !result.confirmed) {
return; // Cancelado por el usuario
}
const customParams = result.params || {};
this.promptQueueAndSendPluginCommand(pluginCmd, customParams);
});
return;
}
this.promptQueueAndSendPluginCommand(pluginCmd, {});
}
private promptQueueAndSendPluginCommand(pluginCmd: PluginCommand, extraParams: Record<string, any>): void {
const requiredOs = (pluginCmd.os || []).map(os => os.toLowerCase());
// 1. Equipos en un sistema operativo no coincidente (ej. Linux u og-live cuando se requiere Windows)
const incompatibleRunningClients = this.clientData.filter(c => {
const status = (c.status || '').toLowerCase();
const isOnline = status !== 'off' && status !== 'disconnected' && status !== '';
if (!isOnline || requiredOs.length === 0) return false;
return !requiredOs.some(req => status.includes(req));
});
// 2. Equipos apagados o desconectados
const offlineClients = this.clientData.filter(c => {
const status = (c.status || '').toLowerCase();
return !status || status === 'off' || status === 'disconnected';
});
// Si hay equipos no listos inmediatamente (apagados o en otro SO), ofrecer encolado
if (offlineClients.length > 0 || incompatibleRunningClients.length > 0) {
const dialogRef = this.dialog.open(QueueConfirmationModalComponent, {
width: '400px',
data: { clients: this.clientData }
});
dialogRef.afterClosed().subscribe((queue: boolean | undefined) => {
if (queue === undefined) {
// El usuario canceló o cerró el modal
return;
}
this.sendPluginCommandRequest(pluginCmd, queue, extraParams);
});
return;
}
// 3. Todos los equipos están en línea en el sistema operativo correcto
this.sendPluginCommandRequest(pluginCmd, false, extraParams);
}
private sendPluginCommandRequest(pluginCmd: PluginCommand, queue: boolean, extraParams: Record<string, any> = {}): void {
if (!pluginCmd.endpoint) {
this.toastService.error('El comando no especifica un endpoint de ejecución', pluginCmd.label);
return;
}
const cleanEndpoint = pluginCmd.endpoint.replace(/^\/api/, '');
const url = `${this.baseUrl}${cleanEndpoint}`;
const clientIds = this.clientData.map(c => c.id || c['@id']);
this.http.post<any>(url, {
clients: clientIds,
queue: queue,
target_os: pluginCmd.os,
...extraParams
}).subscribe({
next: (response) => {
const msg = response?.message || 'Comando procesado correctamente';
this.toastService.success(msg, pluginCmd.label);
this.pluginService.loadActivePluginsStatus().subscribe();
},
error: (error) => {
const errorMsg = error?.error?.message || error?.error?.['hydra:description'] || 'Error al comunicar con el servidor';
this.toastService.error(errorMsg, pluginCmd.label);
}
});
}
}

View File

@ -0,0 +1,41 @@
.form-container {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
}
.full-width {
width: 100%;
}
.hint-text {
margin: 0;
font-size: 12px;
color: rgba(0, 0, 0, 0.6);
}
.mat-dialog-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 12px;
}
.mat-dialog-content {
max-height: 60vh;
overflow-y: auto;
}
.mat-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px;
}
.action-container {
display: flex;
justify-content: flex-end;
gap: 1em;
padding: 1.5em;
}

View File

@ -0,0 +1,34 @@
<h2 mat-dialog-title>{{ 'executeTaskNowModalTitle' | translate }}</h2>
<mat-dialog-content class="form-container">
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'selectTask' | translate }}</mat-label>
<input type="text" matInput [formControl]="taskControl" [matAutocomplete]="taskAuto"
[placeholder]="'labelSearchTask' | translate" #taskInput
#taskAutoTrigger="matAutocompleteTrigger">
<button type="button" mat-icon-button matSuffix tabindex="-1"
[disabled]="tasksLoading || tasks.length === 0"
(click)="taskInput.focus(); taskAutoTrigger.openPanel()">
<mat-icon>arrow_drop_down</mat-icon>
</button>
<mat-autocomplete #taskAuto="matAutocomplete"
[displayWith]="displayFn" autoActiveFirstOption
(optionSelected)="onTaskSelected($event.option.value)">
<mat-option *ngFor="let task of filteredTasks$ | async" [value]="task">
{{ task.name }}
</mat-option>
</mat-autocomplete>
</mat-form-field>
<p *ngIf="tasksLoading" class="hint-text">{{ 'loadingDots' | translate }}</p>
<p *ngIf="!tasksLoading && tasks.length === 0">{{ 'noTasksAvailableMessage' | translate }}</p>
</mat-dialog-content>
<mat-dialog-actions class="action-container">
<button class="ordinary-button" (click)="closeModal()">{{ 'buttonCancel' | translate }}</button>
<button class="submit-button" (click)="executeNow()" [disabled]="!selectedTask">
{{ 'executeButton' | translate }}
</button>
</mat-dialog-actions>

View File

@ -0,0 +1,105 @@
import { inject } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { Component, Inject, OnInit } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { HttpClient } from '@angular/common/http';
import { FormControl } from '@angular/forms';
import { BehaviorSubject, combineLatest, Observable } from 'rxjs';
import { map, startWith } from 'rxjs/operators';
import { ToastrService } from 'ngx-toastr';
import { ConfigService } from '@services/config.service';
@Component({
selector: 'app-execute-task-now',
templateUrl: './execute-task-now.component.html',
styleUrls: ['./execute-task-now.component.css']
})
export class ExecuteTaskNowComponent implements OnInit {
public translate = inject(TranslateService);
baseUrl: string;
tasks: any[] = [];
tasksLoading = true;
taskControl = new FormControl();
filteredTasks$!: Observable<any[]>;
selectedTask: any = null;
private tasks$ = new BehaviorSubject<any[]>([]);
constructor(
private dialogRef: MatDialogRef<ExecuteTaskNowComponent>,
@Inject(MAT_DIALOG_DATA) public data: any,
private http: HttpClient,
private configService: ConfigService,
private toastService: ToastrService,
) {
this.baseUrl = this.configService.apiUrl;
}
ngOnInit(): void {
this.loadTasks();
this.filteredTasks$ = combineLatest([
this.taskControl.valueChanges.pipe(startWith('')),
this.tasks$
]).pipe(
map(([value]) => this._filterTasks(typeof value === 'string' ? value : value?.name))
);
}
loadTasks(): void {
this.http.get<any>(`${this.baseUrl}/command-tasks?page=1&itemsPerPage=100`).subscribe(
response => {
this.tasks = response['hydra:member'] || [];
this.tasksLoading = false;
this.tasks$.next(this.tasks);
},
error => {
this.tasksLoading = false;
this.toastService.error(this.translate.instant('errorLoadingTasks'), error);
}
);
}
private _filterTasks(value: string): any[] {
const filterValue = (value || '').toLowerCase();
return this.tasks.filter(task => task.name.toLowerCase().includes(filterValue));
}
displayFn(task: any): string {
return task && task.name ? task.name : '';
}
onTaskSelected(task: any): void {
this.selectedTask = task;
}
executeNow(): void {
if (!this.selectedTask) {
return;
}
const clients = this.data?.clients || [];
if (clients.length === 0) {
this.toastService.error(this.translate.instant('noClientsSelected'));
return;
}
const payload = {
clients: clients.map((client: any) => client['@id'] || `/clients/${client.uuid}`)
};
this.http.post(`${this.baseUrl}/command-tasks/${this.selectedTask.uuid}/execute`, payload).subscribe({
next: () => {
this.toastService.success(this.translate.instant('taskExecutedSuccessfully'));
this.dialogRef.close(true);
},
error: () => {
this.toastService.error(this.translate.instant('errorExecutingTaskNow'));
}
});
}
closeModal(): void {
this.dialogRef.close(false);
}
}

View File

@ -6,46 +6,13 @@
<mat-divider *ngIf="!loading" style="margin-top: 20px;"></mat-divider>
<div *ngIf="!loading" class="partition-table-container">
<table mat-table [dataSource]="dataSource" class="mat-elevation-z8">
<ng-container matColumnDef="select">
<th mat-header-cell *matHeaderCellDef i18n="@@columnActions" style="text-align: start">{{ 'buttonSelectImage' | translate }}</th>
<td mat-cell *matCellDef="let row">
<mat-radio-group
[(ngModel)]="selectedPartition"
>
<mat-radio-button [value]="row">
</mat-radio-button>
</mat-radio-group>
</td>
</ng-container>
<ng-container *ngFor="let column of columns" [matColumnDef]="column.columnDef">
<th mat-header-cell *matHeaderCellDef> {{ column.header | translate }} </th>
<td mat-cell *matCellDef="let image">
<ng-container *ngIf="column.columnDef !== 'size' && column.columnDef !== 'operativeSystem'">
{{ column.cell(image) }}
</ng-container>
<ng-container *ngIf="column.columnDef === 'size'">
<div style="display: flex; flex-direction: column;">
<span> {{ image.size }} MB</span>
<span style="font-size: 0.75rem; color: gray;">{{ image.size / 1024 }} GB</span>
</div>
</ng-container>
<ng-container *ngIf="column.columnDef === 'operativeSystem'">
<div style="display: flex; flex-direction: column;">
<span> {{ image.operativeSystem?.name }} </span>
<span style="font-size: 0.75rem; color: gray;">{{ image.image?.name}} </span>
</div>
</ng-container>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
<app-disk-partition-selector
[(selectedPartition)]="selectedPartition"
[partitions]="dataSource.data"
[columns]="columns"
[displayedColumns]="displayedColumns"
[disableCondition]="isPartitionDisabled">
</app-disk-partition-selector>
</div>
</mat-dialog-content>

View File

@ -59,6 +59,10 @@ export class SoftwareProfilePartitionComponent implements OnInit{
displayedColumns = ['select', ...this.columns.map(column => column.columnDef)];
isPartitionDisabled = (partition: any): boolean => {
return !partition.operativeSystem;
};
constructor(
@Inject(MAT_DIALOG_DATA) public data: { client: any },
private dialogRef: MatDialogRef<SoftwareProfilePartitionComponent>,
@ -79,7 +83,7 @@ export class SoftwareProfilePartitionComponent implements OnInit{
this.http.get(url).subscribe(
(response: any) => {
if (response.partitions) {
this.dataSource.data = response.partitions;
this.dataSource.data = response.partitions.filter((p: any) => p.partitionCode !== 'CACHE' && p.partitionCode !== 'EMPTY');
}
},
(error) => {

View File

@ -4,6 +4,7 @@ import { HttpClient } from '@angular/common/http';
import { Component, OnInit } from '@angular/core';
import { ConfigService } from '@services/config.service';
import { ToastrService } from "ngx-toastr";
import { AuthService } from '@services/auth.service';
@Component({
selector: 'app-global-status',
@ -67,7 +68,8 @@ export class GlobalStatusComponent implements OnInit {
constructor(
private configService: ConfigService,
private toastService: ToastrService,
private http: HttpClient
private http: HttpClient,
private auth: AuthService
) {
this.baseUrl = this.configService.apiUrl;
this.dhcpsApiUrl = `${this.baseUrl}/dhcp-servers`;
@ -115,7 +117,18 @@ export class GlobalStatusComponent implements OnInit {
});
}
/**
* Las sincronizaciones con la infraestructura (subredes DHCP, plantillas PXE y
* ogLives) están restringidas a ROLE_SUPER_ADMIN en ogCore; para el resto de
* perfiles devuelven "Access Denied", así que ni siquiera se lanzan.
*/
private get canSyncInfrastructure(): boolean {
return this.auth.isSuperAdmin;
}
syncSubnets() {
if (!this.canSyncInfrastructure) return;
this.http.get<any>(`${this.dhcpsApiUrl}?page=1&itemsPerPage=10`).subscribe(data => {
const dhcps = data['hydra:member'] || [];
if (dhcps.length === 0) return;
@ -155,6 +168,7 @@ export class GlobalStatusComponent implements OnInit {
}
syncTemplates() {
if (!this.canSyncInfrastructure) return;
if (this.bootServers.length === 0) return;
let pending = this.bootServers.length;
@ -189,6 +203,7 @@ export class GlobalStatusComponent implements OnInit {
}
syncOgLives(): void {
if (!this.canSyncInfrastructure) return;
if (this.bootServers.length === 0) return;
let pending = this.bootServers.length;

View File

@ -42,15 +42,13 @@
</div>
</div>
<div class="logs-section" id="ogboot-logs-section">
<div class="logs-header">
<h3>{{ 'ogbootLogs' | translate }}</h3>
<p>{{ 'realTimeOgbootLogs' | translate }}</p>
</div>
<div class="logs-container">
<iframe [src]="getBootServerIframeUrl()" width="100%" height="800" frameborder="0" class="ogboot-logs-iframe">
</iframe>
</div>
<div id="ogboot-logs-section">
<app-status-logs
[title]="'ogbootLogs'"
[identifiers]="['ogboot', 'tftpd-hpa']"
[host]="getSelectedBootServerIp()"
[oglogUrl]="getBootServerIframeUrl()">
</app-status-logs>
</div>
</div>
}

View File

@ -1,6 +1,6 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { OgbootStatusComponent } from './ogboot-status.component';
import { GlobalOgbootStatusComponent as OgbootStatusComponent } from './ogboot-status.component';
describe('OgbootStatusComponent', () => {
let component: OgbootStatusComponent;

View File

@ -101,6 +101,11 @@ export class GlobalOgbootStatusComponent implements OnInit {
}
}
getSelectedBootServerIp(): string {
const s = this.bootServers.find(s => s.uuid === this.selectedBootServerUuid);
return s ? s.ip : '';
}
getSelectedBootServerName(): string {
const selectedBootServer = this.bootServers.find(server => server.uuid === this.selectedBootServerUuid);
return selectedBootServer ? selectedBootServer.name : '';

View File

@ -1,14 +1,12 @@
<div class="section-content">
<div class="tab-content">
<div class="logs-section" id="ogcore-logs-section">
<div class="logs-header">
<h3>{{ 'ogcoreLogs' | translate }}</h3>
<p>{{ 'realTimeOgcoreLogs' | translate }}</p>
</div>
<div class="logs-container">
<iframe [src]="getOgCoreIframeUrl()" width="100%" height="800" frameborder="0" class="ogcore-logs-iframe">
</iframe>
</div>
<div id="ogcore-logs-section">
<app-status-logs
[title]="'ogcoreLogs'"
[identifiers]="['ogcore']"
[host]="''"
[oglogUrl]="getOgCoreIframeUrl()">
</app-status-logs>
</div>
</div>
</div>

View File

@ -42,15 +42,13 @@
</div>
</div>
<div class="logs-section" id="dhcp-logs-section">
<div class="logs-header">
<h3>{{ 'dhcpLogs' | translate }}</h3>
<p>{{ 'realTimeDhcpLogs' | translate }}</p>
</div>
<div class="logs-container">
<iframe [src]="getDhcpIframeUrl()" width="100%" height="800" frameborder="0" class="dhcp-logs-iframe">
</iframe>
</div>
<div id="dhcp-logs-section">
<app-status-logs
[title]="'dhcpLogs'"
[identifiers]="['ogdhcp', 'kea-dhcp4', 'kea-ctrl-agent', 'DhcpLFC']"
[host]="getSelectedDhcpIp()"
[oglogUrl]="getDhcpIframeUrl()">
</app-status-logs>
</div>
</div>
}

View File

@ -104,6 +104,11 @@ export class OgdhcpStatusComponent implements OnInit {
}
}
getSelectedDhcpIp(): string {
const d = this.dhcps.find(d => d.uuid === this.selectedDhcpUuid);
return d ? d.ip : '';
}
getSelectedDhcpName(): string {
const selectedDhcp = this.dhcps.find(dhcp => dhcp.uuid === this.selectedDhcpUuid);
return selectedDhcp ? selectedDhcp.name : '';

View File

@ -49,16 +49,13 @@
</div>
</div>
<div class="repository-logs-section" id="repository-logs-section">
<div class="logs-header">
<h3>Datos del repositorio: {{ getSelectedRepositoryName() }}</h3>
<p>{{ 'specificRepoInfo' | translate }}</p>
</div>
<div class="logs-container">
<iframe [src]="getRepositoryIframeUrl()" width="100%" height="600" frameborder="0"
class="repository-logs-iframe">
</iframe>
</div>
<div id="repository-logs-section">
<app-status-logs
[title]="'ogrepositoryLogs'"
[identifiers]="['ogrepo-api', 'ogrepo-api_DEBUG', '(smbd)', '(nmbd)']"
[host]="getSelectedRepositoryIp()"
[oglogUrl]="getRepositoryIframeUrl()">
</app-status-logs>
</div>
</div>
}

View File

@ -103,6 +103,11 @@ export class OgrepositoryStatusComponent implements OnInit {
}
}
getSelectedRepositoryIp(): string {
const r = this.repositories.find(r => r.uuid === this.selectedRepositoryUuid);
return r ? r.ip : '';
}
getSelectedRepositoryName(): string {
const selectedRepo = this.repositories.find(repo => repo.uuid === this.selectedRepositoryUuid);
return selectedRepo ? selectedRepo.name : '';

View File

@ -0,0 +1,165 @@
.logs-section {
background-color: var(--surface-card, #ffffff);
border-radius: 12px;
padding: 16px;
margin-top: 16px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.logs-header {
margin-bottom: 12px;
}
.logs-header h3 {
font-size: 1.1rem;
font-weight: 600;
margin: 0;
color: var(--text-primary, #111827);
}
.logs-header p {
font-size: 0.8rem;
color: var(--text-secondary, #6b7280);
margin: 2px 0 0;
}
.logs-tabs {
margin-top: 8px;
}
.logs-container {
position: relative;
background-color: #0d1117;
border-radius: 8px;
margin-top: 8px;
display: flex;
flex-direction: column;
}
.tab-actions-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 12px;
background: rgba(255, 255, 255, 0.05);
border-top-left-radius: 8px;
border-top-right-radius: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.header-actions {
display: flex;
gap: 4px;
}
.header-actions button {
color: rgba(255, 255, 255, 0.7) !important;
}
.header-actions button:hover:not([disabled]) {
color: white !important;
background: rgba(255, 255, 255, 0.1);
}
.header-actions button[disabled] {
color: rgba(255, 255, 255, 0.2) !important;
}
.header-actions button[color="warn"] {
color: #ff4081 !important;
}
.live-badge {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.7rem;
font-weight: 700;
color: #ff4081;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.live-dot {
width: 6px;
height: 6px;
background-color: #ff4081;
border-radius: 50%;
animation: pulse 2s infinite;
}
.log-viewer {
margin: 0;
padding: 12px;
color: #e6edf3;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.8rem;
line-height: 1.4;
overflow-y: auto;
max-height: 50vh;
white-space: pre-wrap;
word-break: break-all;
}
@keyframes pulse {
0% {
transform: scale(0.95);
box-shadow: 0 0 0 0 rgba(255, 64, 129, 0.7);
}
70% {
transform: scale(1);
box-shadow: 0 0 0 8px rgba(255, 64, 129, 0);
}
100% {
transform: scale(0.95);
box-shadow: 0 0 0 0 rgba(255, 64, 129, 0);
}
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(13, 17, 23, 0.7);
z-index: 10;
}
.error-message {
padding: 32px;
text-align: center;
color: #f85149;
}
.error-message mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
margin-bottom: 16px;
}
.logs-iframe {
border-radius: 8px;
}
.no-oglog {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 64px;
color: #8b949e;
}
.no-oglog mat-icon {
font-size: 48px;
width: 48px;
height: 48px;
margin-bottom: 16px;
}

View File

@ -0,0 +1,59 @@
<div class="logs-section">
<div class="logs-header">
<h3>{{ title | translate }}</h3>
<p>{{ 'realTimeLogs' | translate }}</p>
</div>
<mat-tab-group class="logs-tabs" animationDuration="0ms" (selectedTabChange)="onTabChange($event)">
<mat-tab [label]="'diskLogs' | translate">
<div class="logs-container disk-logs">
<div class="tab-actions-header">
<div class="header-actions">
<button mat-icon-button
(click)="toggleLive()"
[matTooltip]="(isLive ? 'diskLogsStopLive' : 'diskLogsStartLive') | translate"
[color]="isLive ? 'warn' : ''">
<mat-icon>{{ isLive ? 'pause_circle' : 'play_circle' }}</mat-icon>
</button>
<button mat-icon-button (click)="refreshLogs()" [matTooltip]="'buttonUpdateData' | translate" [disabled]="isLive">
<mat-icon>refresh</mat-icon>
</button>
</div>
<div *ngIf="isLive" class="live-badge">
<span class="live-dot"></span>
LIVE
</div>
</div>
@if (loadingDiskLogs && !diskLogsContent) {
<div class="loading-overlay">
<mat-spinner diameter="40"></mat-spinner>
</div>
}
@if (errorDiskLogs) {
<div class="error-message">
<mat-icon>error_outline</mat-icon>
<p>{{ errorDiskLogs }}</p>
<button mat-button (click)="loadDiskLogs()">{{ 'buttonRetry' | translate }}</button>
</div>
}
<pre #logContent class="log-viewer">{{ diskLogsContent }}</pre>
</div>
</mat-tab>
<mat-tab [label]="'oglogLogs' | translate">
<div class="logs-container oglog-logs">
@if (oglogUrl) {
<iframe [src]="oglogUrl" width="100%" height="600" frameborder="0" class="logs-iframe"></iframe>
} @else {
<div class="no-oglog">
<mat-icon>info_outline</mat-icon>
<p>oglog no está disponible o no se ha configurado la URL.</p>
</div>
}
</div>
</mat-tab>
</mat-tab-group>
</div>

View File

@ -0,0 +1,139 @@
import { Component, Input, OnInit, OnDestroy, inject, ViewChild, ElementRef, AfterViewChecked } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { SafeResourceUrl } from '@angular/platform-browser';
import { ConfigService } from '@services/config.service';
import { TranslateService } from '@ngx-translate/core';
import { MatTabChangeEvent } from '@angular/material/tabs';
interface JournalEntry {
MESSAGE: string;
SYSLOG_IDENTIFIER?: string;
_HOSTNAME?: string;
__REALTIME_TIMESTAMP?: string;
PRIORITY?: string;
}
@Component({
selector: 'app-status-logs',
templateUrl: './status-logs.component.html',
styleUrl: './status-logs.component.css'
})
export class StatusLogsComponent implements OnInit, OnDestroy, AfterViewChecked {
@ViewChild('logContent') private logContainer!: ElementRef;
@Input() identifiers: string[] = [];
@Input() host: string = '';
@Input() oglogUrl: SafeResourceUrl | null = null;
@Input() title: string = '';
public translate = inject(TranslateService);
diskLogsContent: string = '';
loadingDiskLogs: boolean = false;
errorDiskLogs: string | null = null;
isLive: boolean = true;
private pollInterval: any;
private readonly POLL_INTERVAL_MS = 5000;
constructor(
private http: HttpClient,
private configService: ConfigService
) {}
ngOnInit(): void {
this.loadDiskLogs();
if (this.isLive) {
this.startPolling();
}
}
ngAfterViewChecked() {
this.scrollToBottom();
}
toggleLive(): void {
this.isLive = !this.isLive;
if (this.isLive) {
this.loadDiskLogs();
this.startPolling();
} else {
this.stopPolling();
}
}
onTabChange(event: MatTabChangeEvent): void {
if (event.index === 0) {
if (this.isLive) {
this.loadDiskLogs();
this.startPolling();
}
} else {
this.stopPolling();
}
}
ngOnDestroy(): void {
this.stopPolling();
}
loadDiskLogs(): void {
if (!this.identifiers.length) return;
if (!this.isLive) {
this.loadingDiskLogs = true;
}
const idParam = this.identifiers.join(',');
const hostParam = this.host ? `&host=${encodeURIComponent(this.host)}` : '';
const url = `${this.configService.apiUrl}/opengnsys/system/disk-logs?SYSLOG_IDENTIFIER=${encodeURIComponent(idParam)}${hostParam}&lines=200`;
this.http.get<{ entries: JournalEntry[] }>(url).subscribe({
next: (response) => {
this.diskLogsContent = this.formatEntries(response.entries || []);
this.loadingDiskLogs = false;
this.errorDiskLogs = null;
},
error: () => {
this.errorDiskLogs = 'Error al cargar logs de disco';
this.loadingDiskLogs = false;
}
});
}
private formatEntries(entries: JournalEntry[]): string {
return entries.map(entry => {
const ts = entry.__REALTIME_TIMESTAMP
? new Date(parseInt(entry.__REALTIME_TIMESTAMP, 10) / 1000).toISOString().replace('T', ' ').substring(0, 23)
: ' ';
const id = entry.SYSLOG_IDENTIFIER ?? '';
return `[${ts}] ${id}: ${entry.MESSAGE ?? ''}`;
}).join('\n');
}
private startPolling(): void {
this.stopPolling();
this.pollInterval = setInterval(() => {
if (this.isLive) {
this.loadDiskLogs();
}
}, this.POLL_INTERVAL_MS);
}
private stopPolling(): void {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
}
refreshLogs(): void {
this.loadDiskLogs();
}
private scrollToBottom(): void {
if (this.isLive && this.logContainer) {
try {
this.logContainer.nativeElement.scrollTop = this.logContainer.nativeElement.scrollHeight;
} catch (err) {}
}
}
}

View File

@ -66,7 +66,8 @@ export class CreateClientImageComponent implements OnInit{
}
isPartitionDisabled = (row: any): boolean => {
return !row.operativeSystem;
// Se debe permitir crear imagen de particiones de sólo datos, tan sólo evitamos partición CACHE
return row.filesystem === 'CACHE';
};
columns = [
@ -125,14 +126,10 @@ export class CreateClientImageComponent implements OnInit{
this.client = response;
this.clientName = response.name;
this.selectedRepository = response.repository;
const validPartitions = response.partitions.filter((partition: any) => {
return partition.partitionNumber !== 0;
});
this.dataSource.data = validPartitions;
this.dataSource.data = response.partitions;
const firstValidPartition = validPartitions.find((partition: any) => {
const firstValidPartition = response.partitions.find((partition: any) => {
return partition.operativeSystem;
});
@ -152,7 +149,8 @@ export class CreateClientImageComponent implements OnInit{
}
loadImages() {
const url = `${this.baseUrl}/images?created=false&type=${this.imageType}&page=1&itemsPerPage=100`;
const repoFilter = this.selectedRepository ? `&repositoryId=${this.selectedRepository.id}` : '';
const url = `${this.baseUrl}/images?created=false&type=${this.imageType}${repoFilter}&page=1&itemsPerPage=100`;
this.http.get(url).subscribe(
(response: any) => {
this.images = response['hydra:member'];
@ -374,7 +372,8 @@ export class CreateClientImageComponent implements OnInit{
width: '400px',
disableClose: true,
hasBackdrop: true,
backdropClass: 'non-clickable-backdrop'
backdropClass: 'non-clickable-backdrop',
data: { clients: this.client ? [this.client] : [] }
});
dialogRef.afterClosed().subscribe(result => {

View File

@ -921,3 +921,13 @@ table {
margin-top: 2px;
}
.post-deploy-radio-group {
margin-top: 10px;
}
.pxe-template-container {
margin-top: 15px;
display: flex;
flex-direction: column;
gap: 10px;
}

View File

@ -52,7 +52,8 @@
</div>
<div class="clients-grid">
<div *ngFor="let client of clientData" class="client-item">
@for (client of clientData; track client) {
<div class="client-item">
<div class="client-card" (click)="toggleClientSelection(client)"
[ngClass]="{'selected-client': client.selected}" [matTooltip]="getPartitionsTooltip(client)"
matTooltipPosition="above" matTooltipClass="custom-tooltip">
@ -73,6 +74,7 @@
</mat-radio-group>
</div>
</div>
}
</div>
</mat-expansion-panel>
@ -99,49 +101,89 @@
</div>
<!-- Campos monolítica -->
<div class="monolithic-row" *ngIf="imageType === 'monolithic'">
<mat-form-field appearance="fill" class="half-width">
@if (imageType === 'monolithic') {
<div class="monolithic-row" style="display: flex; flex-direction: column; gap: 10px;">
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'selectDeployMethod' | translate }}</mat-label>
<mat-select [(ngModel)]="selectedMethod" name="selectedMethod" (selectionChange)="validateImageSize()">
<mat-option *ngFor="let method of allMethods" [value]="method.value">{{ method.name }}</mat-option>
@for (method of allMethods; track method.value) {
<mat-option [value]="method.value">{{ method.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'selectRepository' | translate }}</mat-label>
<mat-select [(ngModel)]="selectedRepository" (selectionChange)="onRepositorySelected($event.value)" [compareWith]="compareRepositories" [disabled]="loadingAllRepositories">
@for (repo of allImageRepositories; track repo.uuid) {
<mat-option [value]="repo">{{ repo.name }}</mat-option>
}
</mat-select>
@if (loadingAllRepositories) {
<mat-icon matSuffix>hourglass_empty</mat-icon>
}
</mat-form-field>
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'selectImagePrompt' | translate }}</mat-label>
<mat-select [(ngModel)]="selectedImage" name="selectedImage" [disabled]="!imageType">
<mat-option *ngFor="let image of images" [value]="image">
@for (image of images; track image.uuid || $index) {
<mat-option [value]="image">
<div class="unit-name"> {{ image.name }}</div>
<div style="font-size: smaller; color: gray;">{{ image.description }}</div>
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
}
<!-- Campos Git -->
<div *ngIf="imageType === 'git'" class="filters-row git-gap">
<mat-form-field appearance="fill" style="width: 300px;">
@if (imageType === 'git') {
<div class="git-gap" style="display: flex; flex-direction: column; gap: 10px;">
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>{{ 'selectRepository' | translate }}</mat-label>
<mat-select [(ngModel)]="selectedRepository" (selectionChange)="onRepositorySelected($event.value)" [compareWith]="compareRepositories" [disabled]="loadingAllRepositories">
@for (repo of allImageRepositories; track repo.uuid) {
<mat-option [value]="repo">{{ repo.name }}</mat-option>
}
</mat-select>
@if (loadingAllRepositories) {
<mat-icon matSuffix>hourglass_empty</mat-icon>
}
</mat-form-field>
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>{{ 'buttonSelectRepository' | translate }}</mat-label>
<mat-select [(ngModel)]="selectedGitRepository" (selectionChange)="onGitRepositoryChange($event.value)"
[disabled]="loadingRepositories">
<mat-option *ngFor="let repo of repositories" [value]="repo">
@for (repo of repositories; track repo) {
<mat-option [value]="repo">
{{ repo }}
</mat-option>
}
</mat-select>
<mat-icon matSuffix *ngIf="loadingRepositories">hourglass_empty</mat-icon>
@if (loadingRepositories) {
<mat-icon matSuffix>hourglass_empty</mat-icon>
}
</mat-form-field>
<mat-form-field appearance="fill" style="width: 300px;">
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>{{ 'buttonSelectBranch' | translate }}</mat-label>
<mat-select [(ngModel)]="selectedBranch" (selectionChange)="onGitBranchChange()"
[disabled]="loadingBranches || !selectedRepository">
<mat-option *ngFor="let branch of branches" [value]="branch">
[disabled]="loadingBranches || !selectedGitRepository">
@for (branch of branches; track branch) {
<mat-option [value]="branch">
{{ branch }}
</mat-option>
}
</mat-select>
<mat-icon matSuffix *ngIf="loadingBranches">hourglass_empty</mat-icon>
@if (loadingBranches) {
<mat-icon matSuffix>hourglass_empty</mat-icon>
}
</mat-form-field>
<mat-form-field appearance="fill" style="width: 200px;">
<mat-form-field appearance="fill" style="width: 100%;">
<mat-label>{{ 'labelTag' | translate }}</mat-label>
<mat-select [(ngModel)]="showOnlyTagged" (selectionChange)="onTagFilterChange()" [disabled]="!commits.length">
<mat-option [value]="false">{{ 'buttonNo' | translate }}</mat-option>
@ -149,6 +191,7 @@
</mat-select>
</mat-form-field>
</div>
}
<!-- Configuración avanzada del método (Multicast / P2P) -->
@if (imageType === 'monolithic' && selectedMethod && needsAdvancedConfig(selectedMethod)) {
@ -182,14 +225,48 @@
}
<div *ngIf="errorMessage" class="error-message">
@if (errorMessage) {
<div class="error-message">
{{ errorMessage }}
</div>
}
</div>
<!-- Columna derecha: modo de operación + partición -->
<div class="partition-column">
<!-- Sección de Acciones Post-Despliegue -->
@if (deployMode !== 'update-cache') {
<div class="form-section compact-section post-deploy-section">
<div class="form-section-title">
<mat-icon>power_settings_new</mat-icon>
{{ 'postDeployAction' | translate }}
</div>
<mat-radio-group [(ngModel)]="postDeployAction" class="deploy-mode-radio-group post-deploy-radio-group">
<mat-radio-button value="none" color="primary" class="deploy-mode-radio">{{ 'postDeployNone' | translate }}</mat-radio-button>
<mat-radio-button value="bootOs" color="primary" class="deploy-mode-radio">{{ 'postDeployBootOs' | translate }}</mat-radio-button>
<mat-radio-button value="pxeTemplate" color="primary" class="deploy-mode-radio">{{ 'postDeployPxeTemplate' | translate }}</mat-radio-button>
</mat-radio-group>
@if (postDeployAction === 'pxeTemplate') {
<div class="pxe-template-container">
<mat-form-field appearance="fill">
<mat-label>{{ 'selectPxeTemplate' | translate }}</mat-label>
<mat-select [(ngModel)]="selectedPxeTemplate">
@for (template of pxeTemplates; track template.uuid) {
<mat-option [value]="template">{{ template.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-slide-toggle [(ngModel)]="pxePermanent" color="primary">
{{ pxePermanent ? ('pxeTemplatePermanent' | translate) : ('pxeTemplateTemporal' | translate) }}
</mat-slide-toggle>
</div>
}
</div>
}
<!-- Modo de despliegue: solo visible para métodos no-direct -->
@if (imageType === 'monolithic' && selectedMethod && !isDirectMethod()) {
@ -221,6 +298,22 @@
</div>
}
<!-- Contenedor para mostrar las instrucciones en cualquier modo -->
@if (showInstructions) {
<div class="instructions-box" style="margin-bottom: 20px;">
<mat-card class="instructions-card">
<mat-card-title>
{{ 'generatedInstructions' | translate }}<button mat-icon-button (click)="showInstructions = false" style="float: right;">
<mat-icon>close</mat-icon>
</button>
</mat-card-title>
<mat-card-content>
<pre>{{ ogInstructions }}</pre>
</mat-card-content>
</mat-card>
</div>
}
<!-- Sección: Selección de partición (oculta en modo solo-caché) -->
@if (deployMode !== 'update-cache') {
<div class="form-section compact-section partition-select-section" id="partition-selection">
@ -229,18 +322,6 @@
{{ 'partitionSelection' | translate }}</div>
<div class="partition-table-container">
<div *ngIf="showInstructions" class="instructions-box">
<mat-card class="instructions-card">
<mat-card-title>
{{ 'generatedInstructions' | translate }}<button mat-icon-button (click)="showInstructions = false" style="float: right;">
<mat-icon>close</mat-icon>
</button>
</mat-card-title>
<mat-card-content>
<pre>{{ ogInstructions }}</pre>
</mat-card-content>
</mat-card>
</div>
<app-disk-partition-selector
[(selectedPartition)]="selectedPartition"
@ -274,7 +355,8 @@
</div>
<!-- Tabla de commits Git (solo cuando hay commits cargados) -->
<div *ngIf="imageType === 'git' && filteredCommits.length > 0" class="form-section compact-section">
@if (imageType === 'git' && filteredCommits.length > 0) {
<div class="form-section compact-section">
<div class="form-section-title">
<mat-icon>history</mat-icon>
{{ 'commitSelection' | translate }}</div>
@ -309,11 +391,15 @@
<th mat-header-cell *matHeaderCellDef>{{ 'labelTags' | translate }}</th>
<td mat-cell *matCellDef="let commit">
<mat-chip-list>
<mat-chip *ngFor="let tag of commit.tags" color="primary" selected class="custom-tag-chip"
@for (tag of commit.tags; track tag) {
<mat-chip color="primary" selected class="custom-tag-chip"
style="background: rgba(102, 126, 234, 0.1) !important; color: #667eea !important; border: 1px solid rgba(102, 126, 234, 0.3) !important; border-radius: 8px !important; font-weight: 500 !important; font-size: 10px !important; padding: 2px 6px !important; margin: 1px !important; box-shadow: none !important;">
{{ tag }}
</mat-chip>
<span *ngIf="!commit.tags || commit.tags.length === 0" class="no-tags-text">{{ 'noTags' | translate }}</span>
}
@if (!commit.tags || commit.tags.length === 0) {
<span class="no-tags-text">{{ 'noTags' | translate }}</span>
}
</mat-chip-list>
</td>
</ng-container>
@ -322,6 +408,7 @@
</table>
</div>
</div>
}
</div>

View File

@ -7,6 +7,7 @@ import { HttpClient } from "@angular/common/http";
import { ToastrService } from "ngx-toastr";
import { ActivatedRoute, Router } from "@angular/router";
import { ConfigService } from '@services/config.service';
import { AuthService } from '@services/auth.service';
import {DeployMethodConfigDialogComponent} from "./deploy-method-config-dialog/deploy-method-config-dialog.component";
import { CreateTaskComponent } from "../../../../commands/commands-task/create-task/create-task.component";
import { MatDialog } from "@angular/material/dialog";
@ -19,6 +20,7 @@ import { QueueConfirmationModalComponent } from "../../../../../shared/queue-con
})
export class DeployImageComponent implements OnInit {
public translate = inject(TranslateService);
public authService = inject(AuthService);
baseUrl: string;
@Output() dataChange = new EventEmitter<any>();
@ -62,7 +64,20 @@ export class DeployImageComponent implements OnInit {
selectedClients: any[] = [];
selectedModelClient: any = null;
filteredPartitions: any[] = [];
private _postDeployAction: string = 'none';
get postDeployAction(): string { return this._postDeployAction; }
set postDeployAction(val: string) {
this._postDeployAction = val;
this.autoSelectPxeTemplate();
}
selectedPxeTemplate: any = null;
pxePermanent: boolean = false;
pxeTemplates: any[] = [];
allImageRepositories: any[] = [];
loadingAllRepositories: boolean = false;
selectedRepository: any = null;
imageType: string = 'monolithic';
@ -145,6 +160,74 @@ export class DeployImageComponent implements OnInit {
this.route.queryParams.subscribe(params => {
this.runScriptContext = params['runScriptContext'] ? JSON.parse(params['runScriptContext']) : null;
});
const username = this.authService.username || 'default';
const savedAction = localStorage.getItem(`deploy_postDeployAction_${username}`);
if (savedAction) {
this.postDeployAction = savedAction;
}
const savedPxePermanent = localStorage.getItem(`deploy_pxePermanent_${username}`);
if (savedPxePermanent !== null) {
this.pxePermanent = savedPxePermanent === 'true';
}
this.loadAllRepositories();
this.loadPxeTemplates();
}
loadPxeTemplates() {
this.http.get<any>(`${this.baseUrl}/pxe-templates?page=1&itemsPerPage=10000`).subscribe(
(response: any) => {
this.pxeTemplates = response?.['hydra:member'] || [];
const username = this.authService.username || 'default';
const savedTemplateUuid = localStorage.getItem(`deploy_selectedPxeTemplateUuid_${username}`);
if (savedTemplateUuid && this.postDeployAction === 'pxeTemplate') {
const match = this.pxeTemplates.find(t => t.uuid === savedTemplateUuid);
if (match) {
this.selectedPxeTemplate = match;
}
}
},
(error) => {
console.error('Error loading PXE templates:', error);
}
);
}
loadAllRepositories() {
this.loadingAllRepositories = true;
this.http.get<any>(`${this.baseUrl}/image-repositories?page=1&itemsPerPage=10000`).subscribe(
(response: any) => {
this.allImageRepositories = response?.['hydra:member'] || [];
this.loadingAllRepositories = false;
// Si ya había un selectedRepository cargado por el cliente, asegurarse de enlazar la misma referencia del array
if (this.selectedRepository) {
const match = this.allImageRepositories.find(r => r.uuid === this.selectedRepository.uuid);
if (match) {
this.selectedRepository = match;
}
}
},
(error) => {
console.error(this.translate.instant('errorLoadingRepositories1'), error);
this.loadingAllRepositories = false;
}
);
}
compareRepositories(r1: any, r2: any): boolean {
return r1 && r2 ? r1.uuid === r2.uuid : r1 === r2;
}
onRepositorySelected(repo: any) {
this.selectedRepository = repo;
if (this.imageType === 'git') {
this.selectedGitRepository = '';
this.selectedBranch = '';
this.loadGitRepositories();
}
this.loadImages();
}
onImageTypeSelected(event: any) {
@ -217,10 +300,25 @@ export class DeployImageComponent implements OnInit {
this.filteredPartitions = fullClientData.partitions;
this.selectedRepository = fullClientData.repository ?? null;
if (this.selectedRepository?.uuid && this.allImageRepositories.length > 0) {
const match = this.allImageRepositories.find(r => r.uuid === this.selectedRepository.uuid);
if (match) {
this.selectedRepository = match;
}
} else if (this.selectedRepository?.uuid) {
// Fallback por si la llamada de loadAllRepositories no ha terminado
this.http.get(`${this.baseUrl}/image-repositories/${this.selectedRepository.uuid}`).subscribe(
(repository: any) => {
this.selectedRepository = repository;
},
(error) => {
console.error(this.translate.instant('errorLoadingRepositories1'), error);
}
);
}
if (fullClientData.partitions) {
this.filteredPartitions = fullClientData.partitions.filter((partition: any) => {
return partition.partitionNumber !== 0;
});
this.filteredPartitions = fullClientData.partitions;
this.p2pMode = fullClientData.organizationalUnit?.networkSettings?.p2pMode;
this.p2pTime = fullClientData.organizationalUnit?.networkSettings?.p2pTime;
this.mcastSpeed = fullClientData.organizationalUnit?.networkSettings?.mcastSpeed;
@ -256,7 +354,9 @@ export class DeployImageComponent implements OnInit {
if (this.imageType === 'monolithic') {
url = `${this.baseUrl}/image-image-repositories?status=success&repository.id=${repositoryId}&page=1&itemsPerPage=1000`;
} else {
url = `${this.baseUrl}/git-image-repositories?status=success&repository.id=${repositoryId}&page=1&itemsPerPage=1000`;
// Para 'git', la carga de repositorios y ramas se maneja con loadGitRepositories() y loadGitBranches()
this.images = [];
return;
}
this.http.get(url).subscribe(
@ -381,7 +481,8 @@ export class DeployImageComponent implements OnInit {
width: '400px',
disableClose: true,
hasBackdrop: true,
backdropClass: 'non-clickable-backdrop'
backdropClass: 'non-clickable-backdrop',
data: { clients: this.clientData }
});
this.toastService.info(this.translate.instant('preparingDeploymentRequest'));
@ -431,12 +532,10 @@ export class DeployImageComponent implements OnInit {
this.http.post(url, payload)
.subscribe({
next: (response) => {
this.toastService.success(this.translate.instant('deployRequestSentCorrectly'));
this.loading = false;
this.router.navigate(['/commands-logs']);
this.enqueuePostDeployActions();
},
error: (error) => {
this.toastService.error(error.error['hydra:description'], 'Se ha detectado un error en el despliegue de imágenes.', {
this.toastService.error(error.error['hydra:description'] || error.message, 'Se ha detectado un error en el despliegue de imágenes.', {
"closeButton": true,
"newestOnTop": false,
"progressBar": false,
@ -452,12 +551,77 @@ export class DeployImageComponent implements OnInit {
});
}
private enqueuePostDeployActions() {
const username = this.authService.username || 'default';
localStorage.setItem(`deploy_postDeployAction_${username}`, this.postDeployAction);
localStorage.setItem(`deploy_pxePermanent_${username}`, String(this.pxePermanent));
if (this.selectedPxeTemplate?.uuid) {
localStorage.setItem(`deploy_selectedPxeTemplateUuid_${username}`, this.selectedPxeTemplate.uuid);
}
if (this.postDeployAction === 'none') {
this.toastService.success(this.translate.instant('deployRequestSentCorrectly'));
this.loading = false;
this.router.navigate(['/commands-logs']);
return;
}
const clientIds = this.selectedClients.map((c: any) => c.uuid);
if (this.postDeployAction === 'bootOs') {
const disk = this.selectedPartition?.diskNumber || 1;
const part = this.selectedPartition?.partitionNumber || 1;
const payload = {
clients: clientIds,
script: `bootOs ${disk} ${part}`,
queue: true,
runAsAdmin: true
};
this.http.post(`${this.baseUrl}/commands/run-script`, payload).subscribe({
next: () => this.finishPostDeploy(),
error: () => this.finishPostDeployWithError()
});
} else if (this.postDeployAction === 'pxeTemplate' && this.selectedPxeTemplate) {
const mode = this.pxePermanent ? 1 : 0;
const combinedScript = `setBootMode ${this.selectedPxeTemplate.name} ${mode}\nreboot`;
const setBootModePayload = {
clients: clientIds,
script: combinedScript,
queue: true,
runAsAdmin: true
};
this.http.post(`${this.baseUrl}/commands/run-script`, setBootModePayload).subscribe({
next: () => this.finishPostDeploy(),
error: () => this.finishPostDeployWithError()
});
} else {
this.finishPostDeploy();
}
}
private finishPostDeploy() {
this.toastService.success(this.translate.instant('deployRequestSentCorrectly') + ' y post-acciones encoladas.');
this.loading = false;
this.router.navigate(['/commands-logs']);
}
private finishPostDeployWithError() {
this.toastService.error('Se programó el despliegue pero falló al encolar las acciones post-despliegue.');
this.loading = false;
this.router.navigate(['/commands-logs']);
}
isFormValid(): boolean {
const needsPartition = this.deployMode !== 'update-cache';
if (!this.allSelected || !this.selectedModelClient || (needsPartition && !this.selectedPartition)) {
return false;
}
if (this.postDeployAction === 'pxeTemplate' && !this.selectedPxeTemplate) {
return false;
}
if (this.imageType === 'git') {
if (!this.selectedCommit || this.filteredCommits.length === 0) {
return false;
@ -586,9 +750,79 @@ export class DeployImageComponent implements OnInit {
});
}
generateOgInstructions() {
this.showInstructions = true;
this.ogInstructions = `og-deploy-image --image ${this.selectedImage.name} --partition ${this.selectedPartition?.partitionNumber || 1} --method ${this.selectedMethod}`;
this.ogInstructions = this.imageType === 'git'
? this.buildGitOgInstructions()
: this.buildMonolithicOgInstructions();
}
private getProto(method: string | null): string {
switch (method) {
case 'uftp-direct':
case 'udpcast-direct':
return 'MULTICAST-DIRECT';
case 'multicast':
case 'uftp':
case 'udpcast':
return 'MULTICAST';
case 'unicast-direct':
return 'UNICAST-DIRECT';
case 'unicast':
return 'UNICAST';
case 'p2p':
return 'TORRENT';
default:
return (method || '').toUpperCase();
}
}
private getProtoOpt(method: string | null): string {
if (method === 'udpcast' || method === 'uftp' || method === 'udpcast-direct') {
const mcastMode = `${this.mcastMode}-duplex`;
const mcastSpeed = `${this.mcastSpeed}M`;
return `${this.mcastPort}:${mcastMode}:${this.mcastIp}:${mcastSpeed}:${this.mcastMaxClients}:${this.mcastMaxTime}`;
}
if (method === 'p2p') {
return `${this.p2pMode}:${this.p2pTime}`;
}
return '';
}
private buildMonolithicOgInstructions(): string {
const method = this.selectedMethod;
const proto = this.getProto(method);
const protoOpt = this.getProtoOpt(method);
const disk = String(this.selectedPartition?.diskNumber || 1);
const par = String(this.selectedPartition?.partitionNumber || 1);
const imgname = this.selectedImage?.name || '<image>';
const repo = this.selectedRepository?.ip || '<repository-ip>';
const effectiveDeployMode = this.isDirectMethod() ? 'deploy' : this.deployMode;
const isCache = effectiveDeployMode === 'update-cache';
const restaurarArgs = [disk, par, imgname, repo, proto, protoOpt].filter(a => a !== '').join(' ');
const scriptName = isCache ? 'ActualizarCache' : 'RestaurarImagen';
const innerCommand = isCache
? ['updateCache', repo, `/${imgname}.img`, proto, protoOpt].filter(a => a !== '').join(' ')
: ['deployImage', repo, imgname, disk, par, proto, protoOpt].filter(a => a !== '').join(' ');
return `${scriptName} ${restaurarArgs}\n ↳ ${innerCommand}`;
}
private buildGitOgInstructions(): string {
const disk = String(this.selectedPartition?.diskNumber || 1);
const par = String(this.selectedPartition?.partitionNumber || 1);
const repo = this.selectedRepository?.ip || '<repository-ip>';
const imageName = this.selectedGitRepository || '<repository-name>';
const branch = this.selectedBranch || '<branch>';
const commit = this.selectedCommit?.hexsha || '<commit>';
return `RestaurarImagenGit --disk ${disk} --partition ${par} --repository ${repo} ` +
`--image-name ${imageName} --branch ${branch} --commit ${commit}`;
}
@ -621,6 +855,32 @@ export class DeployImageComponent implements OnInit {
set selectedPartition(value: any) {
this._selectedPartition = value;
this.autoSelectPxeTemplate();
}
private getOrdinalName(n: number): string {
const ordinals = ['first', 'second', 'third', 'fourth'];
return ordinals[n - 1] || `${n}th`;
}
private autoSelectPxeTemplate(): void {
if (this._postDeployAction !== 'pxeTemplate' || !this._selectedPartition || !this.pxeTemplates) {
return;
}
const disk = this._selectedPartition.diskNumber;
const part = this._selectedPartition.partitionNumber;
if (!disk || !part) return;
const diskOrdinal = this.getOrdinalName(disk);
const partOrdinal = this.getOrdinalName(part);
const expectedName = `${diskOrdinal}Disk_${partOrdinal}Partition`;
const match = this.pxeTemplates.find((t: any) => t.name === expectedName);
if (match) {
this.selectedPxeTemplate = match;
}
}
loadGitRepositories() {

View File

@ -1336,6 +1336,19 @@
border: 2px solid;
}
.badge-disk-type {
display: inline-flex;
align-items: center;
padding: 2px 8px;
font-size: 0.75rem;
font-weight: 600;
color: #1976d2;
background-color: #e3f2fd;
border-radius: 12px;
margin-left: 8px;
vertical-align: middle;
}
.validation-status.loading {
color: #1976d2;
background: #e3f2fd;
@ -1464,3 +1477,6 @@
.badge-disk-type { display: inline-block; padding: 2px 6px; font-size: 11px; font-weight: bold; color: #fff; background-color: #607d8b; border-radius: 4px; margin-left: 8px; vertical-align: middle; }

View File

@ -104,7 +104,11 @@
<mat-option *ngFor="let disk of disks" [value]="disk.diskNumber">
<div class="disk-option">
<div class="disk-info">
<span class="disk-name">Disco {{ disk.diskNumber }}</span>
<span class="disk-name">Disco {{ disk.diskNumber }}
@if (disk.diskType) {
<span class="badge-disk-type">{{ disk.diskType }}</span>
}
</span>
<span class="disk-size"> {{ (disk.totalDiskSize / 1024).toFixed(2) }} GB</span>
</div>
<div class="disk-details">

View File

@ -17,6 +17,7 @@ import { MatTableDataSource } from '@angular/material/table';
interface Partition {
uuid?: string;
partitionNumber: number;
diskType?: string;
size: number;
partitionCode: string;
filesystem: string;
@ -46,7 +47,7 @@ export class PartitionAssistantComponent implements OnInit, AfterViewInit, OnDes
selectedDiskNumber: number | null = null;
updateRequests: any[] = [];
data: any = {};
disks: { diskNumber: number; totalDiskSize: number; partitions: Partition[]; chartData: any[]; used: number; percentage: number }[] = [];
disks: { diskNumber: number; totalDiskSize: number; partitions: Partition[]; chartData: any[]; used: number; percentage: number; diskType?: string }[] = [];
clientData: any = [];
loading: boolean = false;
runScriptContext: any = null;
@ -234,7 +235,7 @@ export class PartitionAssistantComponent implements OnInit, AfterViewInit, OnDes
const partitionsFromData = this.data.partitions;
this.originalPartitions = JSON.parse(JSON.stringify(partitionsFromData));
const disksMap = new Map<number, { totalDiskSize: number; partitions: Partition[] }>();
const disksMap = new Map<number, { totalDiskSize: number; partitions: Partition[]; diskType?: string }>();
partitionsFromData.forEach((partition: any) => {
if (!disksMap.has(partition.diskNumber)) {
@ -244,6 +245,7 @@ export class PartitionAssistantComponent implements OnInit, AfterViewInit, OnDes
const disk = disksMap.get(partition.diskNumber);
if (partition.partitionNumber === 0) {
disk!.totalDiskSize = this.convertBytesToGB(partition.size);
disk!.diskType = partition.diskType;
} else {
const isFirstPartitionGPT = partition.partitionNumber === 1 && this.partitionCode === 'GPT';
const hasValidExistingData = partition.size > 0 && partition.partitionCode && partition.filesystem;
@ -297,7 +299,8 @@ export class PartitionAssistantComponent implements OnInit, AfterViewInit, OnDes
partitions: disk.partitions,
chartData: chartData,
used: used,
percentage: percentage
percentage: percentage,
diskType: disk.diskType
});
});
this.disks.forEach((disk) => {
@ -520,7 +523,8 @@ export class PartitionAssistantComponent implements OnInit, AfterViewInit, OnDes
width: '400px',
disableClose: true,
hasBackdrop: true,
backdropClass: 'non-clickable-backdrop'
backdropClass: 'non-clickable-backdrop',
data: { clients: this.clientData }
});
dialogRef.afterClosed().subscribe(result => {

View File

@ -195,7 +195,8 @@ export class RunScriptAssistantComponent implements OnInit{
const dialogRef = this.dialog.open(QueueConfirmationModalComponent, {
width: '400px',
disableClose: true,
hasBackdrop: true
hasBackdrop: true,
data: { clients: this.selectedClients }
});
dialogRef.afterClosed().subscribe(result => {

View File

@ -1,3 +1,10 @@
:host {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
}
.groups-container {
display: flex;
flex-direction: column;
@ -908,21 +915,22 @@ mat-tree mat-tree-node.disabled:hover {
.remote-pc-badge {
display: inline-flex;
align-items: center;
gap: 2px;
background-color: #1976d2;
color: white;
padding: 2px 6px;
border-radius: 10px;
font-size: 10px;
font-weight: bold;
margin-top: 3px;
gap: 4px;
background-color: #e3f2fd;
color: #1976d2;
font-size: 0.75rem;
font-weight: 600;
padding: 2px 8px;
border-radius: 12px;
width: fit-content;
margin-top: 4px;
}
.remote-pc-icon {
font-size: 12px;
height: 12px;
width: 12px;
line-height: 12px;
font-size: 14px;
width: 14px;
height: 14px;
line-height: 14px;
}
.clients-list {
@ -1597,6 +1605,71 @@ mat-button-toggle-group {
opacity: 1;
}
.badge-net-speed {
display: flex;
align-items: center;
gap: 4px;
background-color: #e3f2fd;
color: #1976d2;
font-size: 0.75rem;
font-weight: 600;
padding: 2px 8px;
border-radius: 12px;
width: fit-content;
margin-top: 4px;
}
.badge-net-speed .net-speed-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
/* 2. MODIFICADORES DE COLOR (Fondo claro + Texto oscuro de la misma gama) */
/* 10 Gbps - Morado (Destaca como velocidad ultra-rápida/premium) */
.badge-net-speed.speed-10000 {
background-color: #f3e5f5; /* Purple 50 */
color: #7b1fa2; /* Purple 700 */
}
/* 2.5 Gbps / 5 Gbps - Azul (El que tú ya tenías) */
.badge-net-speed.speed-5000, .badge-net-speed.speed-2500 {
background-color: #e3f2fd; /* Blue 50 */
color: #1976d2; /* Blue 700 */
}
/* 1 Gbps - Verde (El estándar de red saludable) */
.badge-net-speed.speed-1000 {
background-color: #e8f5e9; /* Green 50 */
color: #388e3c; /* Green 700 */
}
/* 100 Mbps - Naranja (Aviso de enlace inferior/cuello de botella) */
.badge-net-speed.speed-100 {
background-color: #fff3e0; /* Orange 50 */
color: #f57c00; /* Orange 700 */
}
/* 10 Mbps - Rojo (Aviso crítico / Posible cable roto) */
.badge-net-speed.speed-10 {
background-color: #ffebee; /* Red 50 */
color: #d32f2f; /* Red 700 */
}
/* Desconectado / Down - Gris (Neutro) */
.badge-net-speed.speed-down {
background-color: #f5f5f5; /* Grey 100 */
color: #616161; /* Grey 700 */
}
.list-view-badge {
display: inline-flex;
margin-top: 0;
margin-left: 8px;
vertical-align: middle;
}
.stat-icon {
display: flex;
align-items: center;
@ -1826,3 +1899,228 @@ mat-button-toggle-group {
height: 18px;
}
/* ==========================================================================
SOLUCIÓN FINAL: EXPANSIÓN VERTICAL Y ANCHO DINÁMICO (PC Y MÓVIL)
========================================================================== */
/* 1. ESCRITORIO / PC (> 992px): Expansión vertical 100% + Ancho dinámico */
@media (min-width: 993px) {
.main-container {
flex: 1 1 auto !important;
height: 100% !important;
display: flex !important;
flex-direction: row !important;
align-items: stretch !important;
overflow: hidden !important;
}
.filters-and-tree-container {
display: flex !important;
flex-direction: column !important;
height: 100% !important;
min-height: 0 !important;
flex: 0 0 auto !important;
width: fit-content !important;
min-width: 340px !important;
max-width: 45vw !important;
}
.filters-panel {
flex: 0 0 auto !important;
}
.tree-container {
display: flex !important;
flex-direction: column !important;
flex: 1 1 auto !important; /* Estira el panel del árbol hasta abajo */
height: 100% !important;
min-height: 0 !important;
max-height: none !important;
overflow: hidden !important;
}
/* REGLA CLAVE PARA RECUPERAR LA VERTICALIDAD DEL ÁRBOL EN PC */
.modern-tree {
flex: 1 1 auto !important; /* Obliga al mat-tree a llenar el espacio vertical */
height: 100% !important;
max-height: none !important; /* Elimina cualquier límite previo de altura */
overflow-y: auto !important; /* Scrollbar vertical limpia si hay muchas aulas */
}
.clients-container {
flex: 1 1 auto !important;
min-width: 0 !important;
height: 100% !important;
display: flex !important;
flex-direction: column !important;
overflow: hidden !important;
}
/* Forzar a los hijos de clients-container a respetar el límite de altura */
.clients-view, .clients-view > div, .list-view {
display: flex !important;
flex-direction: column !important;
flex: 1 1 auto !important;
min-height: 0 !important;
height: 100% !important;
}
}
/* 2. MÓVILES Y TABLETS (<= 992px): Disposición apilada verticalmente */
@media (max-width: 992px) {
.main-container {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
height: auto !important;
overflow-x: hidden !important;
overflow-y: auto !important;
}
.filters-and-tree-container {
width: 100% !important;
max-width: 100% !important;
min-width: 0 !important;
flex: none !important;
height: auto !important;
max-height: 350px !important;
}
.node-content, .node-main {
white-space: normal !important;
}
.clients-container {
width: 100% !important;
margin-left: 0 !important;
flex: none !important;
height: auto !important;
}
}
.cloning-cell {
display: flex;
flex-direction: column;
}
.image-name {
font-weight: 500;
color: #333;
}
.image-date {
font-size: 0.8em;
color: #666;
font-style: italic;
}
.multi-image-badge {
background-color: #673ab7;
color: #ffffff;
border-radius: 12px;
padding: 2px 6px;
font-size: 0.75em;
font-weight: bold;
cursor: help;
}
.tree-resizer {
width: 12px;
cursor: col-resize;
display: flex;
justify-content: center;
background-color: transparent;
z-index: 10;
transition: background-color 0.2s;
}
.tree-resizer:hover, .tree-resizer.resizing {
background-color: rgba(0, 0, 0, 0.05);
}
.resizer-line {
width: 1px;
background-color: rgba(0, 0, 0, 0.12);
height: 100%;
}
.main-container.resizing {
user-select: none;
}
.client-image-spacing {
margin-top: 0.5em;
}
.multi-image-container {
display: flex;
align-items: center;
gap: 4px;
}
.plugin-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 12px;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.2px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
transition: all 0.2s ease;
width: fit-content;
}
.plugin-badge .plugin-badge-icon {
font-size: 14px;
width: 14px;
height: 14px;
line-height: 14px;
}
.plugin-badge.badge-neutral {
background: #f1f5f9;
color: #64748b;
border: 1px solid #cbd5e1;
}
.plugin-badge.badge-pending {
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
color: #b45309;
border: 1px solid #fcd34d;
}
.plugin-badge.badge-error {
background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
color: #b91c1c;
border: 1px solid #fca5a5;
}
.plugin-badge.badge-info {
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
color: #1d4ed8;
border: 1px solid #93c5fd;
}
.plugin-badge.badge-success {
background: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%);
color: #047857;
border: 1px solid #6ee7b7;
}
.plugin-badge.badge-warning {
background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%);
color: #b45309;
border: 1px solid #fde68a;
}
.plugin-badge.list-view-badge {
margin-top: 2px;
}

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
import { inject } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { Component, OnInit, OnDestroy, ViewChild, QueryList, ViewChildren, ChangeDetectorRef } from '@angular/core';
import { Component, OnInit, OnDestroy, ViewChild, QueryList, ViewChildren, ChangeDetectorRef, HostListener } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Router } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
@ -30,12 +30,13 @@ import { MatMenuTrigger } from '@angular/material/menu';
import { ClientDetailsComponent } from './shared/client-details/client-details.component';
import { PartitionTypeOrganizatorComponent } from './shared/partition-type-organizator/partition-type-organizator.component';
import { ClientTaskLogsComponent } from '../task-logs/client-task-logs/client-task-logs.component';
import {ChangeParentComponent} from "./shared/change-parent/change-parent.component";
import { ChangeParentComponent } from "./shared/change-parent/change-parent.component";
import { AuthService } from '@services/auth.service';
import { ClientPendingTasksComponent } from '../task-logs/client-pending-tasks/client-pending-tasks.component';
import { ClientLogsModalComponent } from './shared/client-logs-modal/client-logs-modal.component';
import { ClientDiskLogsModalComponent } from './shared/client-disk-logs-modal/client-disk-logs-modal.component';
import { DeleteResultsModalComponent } from './shared/delete-results-modal/delete-results-modal.component';
import { PluginService, PluginBadge } from '@services/plugin.service';
enum NodeType {
OrganizationalUnit = 'organizational-unit',
@ -88,6 +89,7 @@ export class GroupsComponent implements OnInit, OnDestroy {
private clientFilterSubject = new Subject<string>();
loading = false;
showOnlyDirectClients: boolean = false;
showSearchFilters: boolean = false;
// Nuevas propiedades para funcionalidades mejoradas
selectedClient: any = null;
@ -102,15 +104,17 @@ export class GroupsComponent implements OnInit, OnDestroy {
online: number;
busy: number;
} = {
total: 0,
off: 0,
online: 0,
busy: 0
};
total: 0,
off: 0,
online: 0,
busy: 0
};
// Tipos de firmware disponibles
firmwareTypes: string[] = [];
private fetchClientsSubscription?: Subscription;
protected status = [
{ value: 'off', name: 'Apagado' },
{ value: 'initializing', name: 'Inicializando' },
@ -124,7 +128,54 @@ export class GroupsComponent implements OnInit, OnDestroy {
{ value: 'disconnected', name: 'Desconectado' }
];
displayedColumns: string[] = ['select', 'status', 'ip', 'firmwareType', 'name', 'oglive', 'subnet', 'pxeTemplate', 'actions'];
displayedColumns: string[] = ['select', 'status', 'ip', 'firmwareType', 'imageCloning', 'name', 'oglive', 'subnet', 'pxeTemplate', 'actions'];
getImagesFromClient(client: any): any[] {
if (!client) return [];
const images: any[] = [];
const partitions = client.partitions ||
(client.disks ? client.disks.flatMap((d: any) => d.partitions || []) : []);
const dateKeys = ['lastRestored', 'restoredAt', 'lastCloned', 'cloningDate', 'updatedAt', 'createdAt', 'date'];
const extractDate = (obj: any): string | null => {
if (!obj) return null;
for (const key of dateKeys) {
if (obj[key]) return obj[key];
}
return null;
};
for (const part of partitions) {
const date = extractDate(part.image) || extractDate(part) || extractDate(client);
if (part.image?.name) {
images.push({ name: part.image.name, date });
} else if (typeof part.image === 'string' && part.image.trim()) {
images.push({ name: part.image, date });
} else if (part.operatingSystem?.name) {
images.push({ name: part.operatingSystem.name, date });
} else if (part.os?.name) {
images.push({ name: part.os.name, date });
}
}
return images;
}
getMultiImageTooltip(images: any[]): string {
if (!images || images.length <= 1) return '';
return images.slice(1).map(img => {
let txt = img.name;
if (img.date) {
const d = new Date(img.date);
txt += ` (Desplegada: ${d.toLocaleDateString()} ${d.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})})`;
}
return txt;
}).join('\n');
}
getPluginBadges(client: any): PluginBadge[] {
return this.pluginService.getClientBadges(client);
}
private subscriptions: Subscription = new Subscription();
@ -140,6 +191,7 @@ export class GroupsComponent implements OnInit, OnDestroy {
public auth: AuthService,
private configService: ConfigService,
private cd: ChangeDetectorRef,
public pluginService: PluginService,
) {
this.baseUrl = this.configService.apiUrl;
this.mercureUrl = this.configService.mercureUrl;
@ -161,12 +213,25 @@ export class GroupsComponent implements OnInit, OnDestroy {
if (!this.pageSizeOptions.includes(this.itemsPerPage)) {
this.pageSizeOptions = [...this.pageSizeOptions, this.itemsPerPage].sort((a, b) => a - b);
}
// Load search filters visibility state from localStorage, default false
const savedShowSearch = localStorage.getItem('showSearchFilters');
this.showSearchFilters = savedShowSearch ? JSON.parse(savedShowSearch) : false;
}
toggleSearchFilters(): void {
this.showSearchFilters = !this.showSearchFilters;
localStorage.setItem('showSearchFilters', JSON.stringify(this.showSearchFilters));
}
ngOnInit(): void {
const savedWidth = localStorage.getItem('oggui_tree_panel_width_' + (this.auth.username || 'default'));
if (savedWidth) {
this.treePanelWidth = parseInt(savedWidth, 10);
}
this.updateGridCols();
this.refreshData();
this.refreshData(localStorage.getItem('lastSelectedOU') ?? undefined);
window.addEventListener('resize', this.updateGridCols);
this.selectedClients.filterPredicate = (client: Client, filter: string): boolean => {
@ -205,7 +270,14 @@ export class GroupsComponent implements OnInit, OnDestroy {
this.breakpointObserver.observe(['(max-width: 992px)']).subscribe((result) => {
this.isSmallScreen = result.matches;
})
});
this.subscriptions.add(
this.pluginService.statusUpdates$.subscribe(() => {
this.cd.markForCheck();
this.cd.detectChanges();
})
);
}
private updateClientStatus(clientUuid: string, status: string): void {
@ -338,12 +410,12 @@ export class GroupsComponent implements OnInit, OnDestroy {
this.treeDataSource.data = [...this.originalTreeData];
if (selectedNodeIdOrUuid) {
this.selectedNode = this.findNodeByIdOrUuid(this.treeDataSource.data, selectedNodeIdOrUuid);
}
if (this.selectedNode) {
this.treeControl.collapseAll();
this.expandPathToNode(this.selectedNode);
this.fetchClientsForNode(this.selectedNode, selectedClientsBeforeEdit);
}
if (selectedNodeIdOrUuid && this.selectedNode) {
this.treeControl.collapseAll();
this.expandPathToNode(this.selectedNode);
this.fetchClientsForNode(this.selectedNode, selectedClientsBeforeEdit);
} else {
this.treeControl.collapseAll();
if (this.treeDataSource.data.length > 0) {
@ -417,23 +489,31 @@ export class GroupsComponent implements OnInit, OnDestroy {
onNodeClick(event: MouseEvent, node: TreeNode): void {
event.stopPropagation();
this.selectedNode = node;
this.persistSelectedNode(node);
const selectedClientsBeforeEdit = this.selection.selected.map(client => client.uuid);
this.fetchClientsForNode(node, selectedClientsBeforeEdit);
if (node.type === NodeType.Classroom || node.type === NodeType.ClientsGroup) {
this.getStatusForAllClientsInNode(node);
}
const checkStatusAfterLoad = node.type === NodeType.Classroom || node.type === NodeType.ClientsGroup;
this.fetchClientsForNode(node, selectedClientsBeforeEdit, checkStatusAfterLoad);
}
onMenuClick(event: Event, node: any): void {
event.stopPropagation();
this.selectedNode = node;
this.persistSelectedNode(node);
const selectedClientsBeforeEdit = this.selection.selected.map(client => client.uuid);
this.fetchClientsForNode(node, selectedClientsBeforeEdit);
}
private persistSelectedNode(node: TreeNode): void {
if (node.uuid) {
localStorage.setItem('lastSelectedOU', node.uuid);
}
}
public fetchClientsForNode(node: any, selectedClientsBeforeEdit: string[] = []): void {
public fetchClientsForNode(node: any, selectedClientsBeforeEdit: string[] = [], checkStatusAfterLoad: boolean = false): void {
const params = new HttpParams({ fromObject: this.filters });
// Agregar parámetros de ordenamiento al backend
@ -442,8 +522,12 @@ export class GroupsComponent implements OnInit, OnDestroy {
backendParams['order[' + this.sortBy + ']'] = this.sortDirection;
}
if (this.fetchClientsSubscription) {
this.fetchClientsSubscription.unsubscribe();
}
this.isLoadingClients = true;
this.http.get<any>(`${this.baseUrl}/clients?organizationalUnit.id=${node.id}&page=${this.page + 1}&itemsPerPage=${this.itemsPerPage}`, { params: backendParams }).subscribe({
this.fetchClientsSubscription = this.http.get<any>(`${this.baseUrl}/clients?organizationalUnit.id=${node.id}&page=${this.page + 1}&itemsPerPage=${this.itemsPerPage}`, { params: backendParams }).subscribe({
next: (response: any) => {
let members = response['hydra:member'];
let total = response['hydra:totalItems'];
@ -471,11 +555,17 @@ export class GroupsComponent implements OnInit, OnDestroy {
this.selection.select(client);
}
});
if (this.isCheckingNodeStatus) {
members.forEach((c: Client) => c.uuid && this.syncingClientIds.add(c.uuid));
}
if (checkStatusAfterLoad) {
this.getStatusForAllClientsInNode(node);
}
this.pluginService.loadActivePluginsStatus().subscribe();
// Calcular estadísticas después de cargar los clientes
this.calculateLocalStats();
},
@ -515,7 +605,7 @@ export class GroupsComponent implements OnInit, OnDestroy {
}
this.loading = false;
});
}
@ -855,36 +945,40 @@ export class GroupsComponent implements OnInit, OnDestroy {
}
private checkStatusSubscription?: Subscription;
getStatusForAllClientsInNode(node: any): void {
if (!node.uuid) return;
if (this.checkStatusSubscription) {
this.checkStatusSubscription.unsubscribe();
}
this.isCheckingNodeStatus = true;
this.selectedClients.data.forEach(c => c.uuid && this.syncingClientIds.add(c.uuid));
this.subscriptions.add(
this.http.post<{ results: Array<{ uuid: string; ip: string; status: string; success: boolean }> }>(
`${this.baseUrl}/organizational-units/${node.uuid}/agent/status`,
{}
).subscribe({
next: (response) => {
this.isCheckingNodeStatus = false;
const statusMap = new Map(response.results.map(r => [r.uuid, r.status]));
response.results.forEach(r => this.syncingClientIds.delete(r.uuid));
this.selectedClients.data = this.selectedClients.data.map(client => {
const newStatus = statusMap.get(client.uuid);
return newStatus ? { ...client, status: newStatus } : client;
});
this.arrayClients = this.selectedClients.data;
this.calculateLocalStats();
this.toastr.success(this.translate.instant('classroomStatusUpdated'));
},
error: () => {
this.isCheckingNodeStatus = false;
this.selectedClients.data.forEach(c => c.uuid && this.syncingClientIds.delete(c.uuid));
this.toastr.error(this.translate.instant('errorCheckingStatus'));
},
})
);
this.checkStatusSubscription = this.http.post<{ results: Array<{ uuid: string; ip: string; status: string; success: boolean }> }>(
`${this.baseUrl}/organizational-units/${node.uuid}/agent/status`,
{}
).subscribe({
next: (response) => {
this.isCheckingNodeStatus = false;
const statusMap = new Map(response.results.map(r => [r.uuid, r.status]));
response.results.forEach(r => this.syncingClientIds.delete(r.uuid));
this.selectedClients.data = this.selectedClients.data.map(client => {
const newStatus = statusMap.get(client.uuid);
return newStatus ? { ...client, status: newStatus } : client;
});
this.arrayClients = this.selectedClients.data;
this.calculateLocalStats();
this.toastr.success(this.translate.instant('classroomStatusUpdated'));
},
error: () => {
this.isCheckingNodeStatus = false;
this.selectedClients.data.forEach(c => c.uuid && this.syncingClientIds.delete(c.uuid));
this.toastr.error(this.translate.instant('errorCheckingStatus'));
},
});
}
@ -966,11 +1060,11 @@ export class GroupsComponent implements OnInit, OnDestroy {
const selectedClientNames = clientData.map(client => client.name);
if (
this.selectedNode &&
this.selectedNode &&
clientData.length === this.selectedClients.data.length &&
selectedClientNames.every(name => this.selectedClients.data.some(c => c.name === name))
) {
return this.selectedNode;
return this.selectedNode;
} else if (clientData.length === 1) {
return clientData[0];
} else if (clientData.length > 1) {
@ -995,7 +1089,7 @@ export class GroupsComponent implements OnInit, OnDestroy {
});
}
changeParent(event: MouseEvent, ): void {
changeParent(event: MouseEvent,): void {
event.stopPropagation();
const dialogRef = this.dialog.open(ChangeParentComponent, {
@ -1106,7 +1200,7 @@ export class GroupsComponent implements OnInit, OnDestroy {
this.http.get<any>(`${this.baseUrl}/clients?organizationalUnit.id=${node.id}&page=1&itemsPerPage=10000`).subscribe({
next: (response) => {
const allClients = response['hydra:member'] || [];
if (allClients.length === 0) {
this.toastr.warning(this.translate.instant('thisOrganizationalUnitDoesNotHave'));
return;
@ -1230,7 +1324,7 @@ export class GroupsComponent implements OnInit, OnDestroy {
}
getStatusCount(status: string): number {
switch(status) {
switch (status) {
case 'off':
return this.totalStats.off;
case 'online':
@ -1244,12 +1338,39 @@ export class GroupsComponent implements OnInit, OnDestroy {
// Métodos para el árbol mejorado
expandAll(): void {
this.treeControl.expandAll();
isTreeResizing: boolean = false;
treePanelWidth: number = 280; // width base original ajustado
onTreeResizeStart(event: MouseEvent) {
this.isTreeResizing = true;
event.preventDefault(); // Previene la selección de texto al arrastrar
}
collapseAll(): void {
this.treeControl.collapseAll();
@HostListener('window:mousemove', ['$event'])
onTreeResizeMove(event: MouseEvent) {
if (!this.isTreeResizing) return;
this.treePanelWidth += event.movementX;
if (this.treePanelWidth < 250) this.treePanelWidth = 250;
if (this.treePanelWidth > 800) this.treePanelWidth = 800;
}
@HostListener('window:mouseup', ['$event'])
onTreeResizeEnd(event: MouseEvent) {
if (this.isTreeResizing) {
this.isTreeResizing = false;
localStorage.setItem('oggui_tree_panel_width_' + (this.auth.username || 'default'), this.treePanelWidth.toString());
}
}
isTreeExpanded: boolean = false;
toggleExpandAll(): void {
if (this.isTreeExpanded) {
this.treeControl.collapseAll();
} else {
this.treeControl.expandAll();
}
this.isTreeExpanded = !this.isTreeExpanded;
}
getNodeTypeTooltip(nodeType: string): string {
@ -1336,7 +1457,7 @@ export class GroupsComponent implements OnInit, OnDestroy {
deleteMultipleClients(event: MouseEvent): void {
event.stopPropagation();
if (this.selection.selected.length === 0) {
this.toastr.warning(this.translate.instant('thereAreNoClientsSelectedFor'));
return;
@ -1344,10 +1465,10 @@ export class GroupsComponent implements OnInit, OnDestroy {
const selectedClients = this.selection.selected;
const clientNames = selectedClients.map(client => client.name).join(', ');
const dialogRef = this.dialog.open(DeleteModalComponent, {
width: '500px',
data: {
data: {
name: `${selectedClients.length} cliente${selectedClients.length > 1 ? 's' : ''}`,
message: `¿Está seguro de que desea eliminar los siguientes clientes?\n\n${clientNames}`,
isMultiple: true
@ -1369,7 +1490,7 @@ export class GroupsComponent implements OnInit, OnDestroy {
next: (response) => {
this.loading = false;
this.showDeletionResults(response, selectedClients);
this.refreshData(this.selectedNode?.id);
this.selection.clear();
},

View File

@ -42,6 +42,8 @@ export interface Client {
type: string;
serialNumber: string;
netiface: string;
netDriver?: string;
netSpeed?: number;
organizationalUnit: OrganizationalUnit;
partitions: any[];
createdAt: string;
@ -70,6 +72,7 @@ export interface TreeNode {
type: string;
'@id'?: string;
remotePc?: boolean;
remotePcRebootOnUnreserve?: boolean;
children?: TreeNode[];
hasClients?: boolean;
clients?: Client[];
@ -86,6 +89,7 @@ export interface FlatNode {
expandable: boolean;
hasClients?: boolean;
remotePc?: boolean;
remotePcRebootOnUnreserve?: boolean;
networkSettings?: Object;
ip?: string;
'@id'?: string;

View File

@ -1,12 +1,12 @@
<h2 mat-dialog-title>{{ 'moveClientsTo' | translate }}</h2>
<mat-dialog-content>
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'selectClassroomPrompt' | translate }}</mat-label>
<mat-select [(ngModel)]="newOU">
<mat-option *ngFor="let unit of units" [value]="unit">{{ unit.name }}</mat-option>
</mat-select>
</mat-form-field>
<app-ou-select
class="full-width"
[(ngModel)]="newOU"
[units]="units"
[label]="'selectClassroomPrompt'">
</app-ou-select>
</mat-dialog-content>
<div class="action-container">

View File

@ -6,6 +6,7 @@ import {MAT_DIALOG_DATA, MatDialogRef} from "@angular/material/dialog";
import {ToastrService} from "ngx-toastr";
import {Router} from "@angular/router";
import {ConfigService} from "@services/config.service";
import {DataService} from "../../services/data.service";
@Component({
selector: 'app-change-parent',
@ -25,6 +26,7 @@ export class ChangeParentComponent implements OnInit {
private toastService: ToastrService,
private router: Router,
private configService: ConfigService,
private dataService: DataService,
@Inject(MAT_DIALOG_DATA) public data: { clients: any}
) {
this.baseUrl = this.configService.apiUrl;
@ -38,7 +40,11 @@ export class ChangeParentComponent implements OnInit {
loadUnits() {
this.http.get<any>(`${this.baseUrl}/organizational-units?page=1&itemsPerPage=500`).subscribe(
response => {
this.units = response['hydra:member'];
const allUnits = response['hydra:member'];
this.units = allUnits.map((unit: any) => ({
...unit,
path: this.dataService.getOrganizationalUnitPath(unit, allUnits)
}));
this.loading = false;
},
error => console.error(this.translate.instant('errorFetchingOrganizationalUnits1'), error)
@ -46,9 +52,15 @@ export class ChangeParentComponent implements OnInit {
}
save() {
let unitIri = this.newOU;
if (typeof this.newOU === 'number' || (typeof this.newOU === 'string' && !this.newOU.includes('/'))) {
const selectedUnit = this.units.find(u => u.id === Number(this.newOU));
unitIri = selectedUnit ? (selectedUnit['@id'] || `/organizational-units/${selectedUnit.uuid}`) : this.newOU;
}
this.http.post<any>(`${this.baseUrl}/clients/change-organizational-unit`, {
clients: this.data.clients.map((client: any) => client['@id']),
organizationalUnit: this.newOU['@id']
organizationalUnit: unitIri
}).subscribe({
next: (response) => {
this.toastService.success(this.translate.instant('parentChangedSuccessfully'));

View File

@ -143,19 +143,21 @@ mat-dialog-content {
.remote-pc-badge {
display: inline-flex;
align-items: center;
gap: 2px;
background-color: #1976d2;
color: white;
padding: 2px 6px;
border-radius: 10px;
font-size: 10px;
font-weight: bold;
margin-top: 3px;
gap: 4px;
background-color: #e3f2fd;
color: #1976d2;
font-size: 0.75rem;
font-weight: 600;
padding: 2px 8px;
border-radius: 12px;
width: fit-content;
margin-top: 4px;
}
.remote-pc-icon {
font-size: 12px;
height: 12px;
width: 12px;
line-height: 12px;
font-size: 14px;
width: 14px;
height: 14px;
line-height: 14px;
}

View File

@ -34,5 +34,6 @@
</button>
<app-loading *ngIf="syncingUds" [isLoading]="syncingUds"></app-loading>
</ng-container>
<button class="ordinary-button" mat-dialog-close>{{ 'buttonCancel' | translate }}</button>
<button class="submit-button" (click)="saveDisposition()">{{ 'saveDispositionButton' | translate }}</button>
</mat-dialog-actions>

View File

@ -191,6 +191,18 @@ table {
color: #3f51b5;
font-weight: bold;
}
.badge-disk-type {
display: inline-flex;
align-items: center;
padding: 2px 8px;
font-size: 0.75rem;
font-weight: 600;
color: #1976d2;
background-color: #e3f2fd;
border-radius: 12px;
margin-left: 8px;
vertical-align: middle;
}
.disk-card-metrics-wrapper {
display: flex;
align-items: center;
@ -262,3 +274,67 @@ table {
font-style: italic;
margin-top: 2px;
}
.partition-badges-row {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 4px;
}
.plugin-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 12px;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.2px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
transition: all 0.2s ease;
width: fit-content;
}
.plugin-badge .plugin-badge-icon {
font-size: 14px;
width: 14px;
height: 14px;
line-height: 14px;
}
.plugin-badge.badge-neutral {
background: #f1f5f9;
color: #64748b;
border: 1px solid #cbd5e1;
}
.plugin-badge.badge-pending {
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
color: #b45309;
border: 1px solid #fcd34d;
}
.plugin-badge.badge-error {
background: linear-gradient(135deg, #fee2e2 0%, #fecaca 100%);
color: #b91c1c;
border: 1px solid #fca5a5;
}
.plugin-badge.badge-info {
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
color: #1d4ed8;
border: 1px solid #93c5fd;
}
.plugin-badge.badge-success {
background: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%);
color: #047857;
border: 1px solid #6ee7b7;
}
.plugin-badge.badge-warning {
background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%);
color: #b45309;
border: 1px solid #fde68a;
}

View File

@ -46,7 +46,11 @@
<div class="disk-card-wrapper">
<mat-card class="disk-mat-card mat-elevation-z4">
<div class="disk-card-header">
<h3 class="disk-card-title">Disco {{ disk.diskNumber }}</h3>
<h3 class="disk-card-title">Disco {{ disk.diskNumber }}
@if (disk.diskType) {
<span class="badge-disk-type">{{ disk.diskType }}</span>
}
</h3>
<div class="disk-card-metrics-wrapper">
<mat-chip class="disk-card-metrics-chip">
Total: {{ disk.total.toFixed(2) }} GB &nbsp;|&nbsp; Usado: {{ (disk.used).toFixed(2) }} GB ({{
@ -87,6 +91,13 @@
@if (image.deployDate) {
<span class="image-date">Desplegada: {{ image.deployDate | date:'dd/MM/yyyy HH:mm' }}</span>
}
<div class="partition-badges-row">
@for (badge of getPartitionBadges(disk.diskNumber, image.partitionNumber); track badge.id + '-' + badge.type + '-' + badge.label + '-' + (badge.tooltip || '')) {
<span class="plugin-badge badge-{{badge.type}}" [matTooltip]="badge.tooltip">
<mat-icon class="plugin-badge-icon">{{ badge.icon }}</mat-icon> {{ badge.label }}
</span>
}
</div>
}
</div>
}

View File

@ -10,6 +10,7 @@ import { Router } from "@angular/router";
import { ToastrService } from "ngx-toastr";
import { ManageClientComponent } from "../../shared/clients/manage-client/manage-client.component";
import { ConfigService } from '@services/config.service';
import { PluginBadge, PluginService } from '@services/plugin.service';
interface ClientInfo {
property: string;
@ -23,6 +24,7 @@ interface ClientInfo {
})
export class ClientDetailsComponent {
public translate = inject(TranslateService);
private pluginService = inject(PluginService);
baseUrl: string;
@ViewChild('assistantContainer') assistantContainer!: ElementRef;
clientUuid: string;
@ -133,7 +135,7 @@ export class ClientDetailsComponent {
}
calculateDiskUsage() {
const diskUsageMap = new Map<number, { total: number, used: number, partitions: any[] }>();
const diskUsageMap = new Map<number, { total: number, used: number, partitions: any[], diskType?: string }>();
this.partitions.forEach((partition: any) => {
const diskNumber = partition.diskNumber;
@ -146,13 +148,14 @@ export class ClientDetailsComponent {
if (partition.partitionNumber === 0) {
diskData!.total = Number((partition.size / 1024).toFixed(2));
diskData!.diskType = partition.diskType;
} else {
diskData!.used += Number((partition.size / 1024).toFixed(2));
diskData!.partitions.push(partition);
}
});
this.chartDisk = Array.from(diskUsageMap.entries()).map(([diskNumber, { total, used, partitions }]) => {
this.chartDisk = Array.from(diskUsageMap.entries()).map(([diskNumber, { total, used, partitions, diskType }]) => {
const partitionData = partitions.map(partition => ({
name: `${this.translate.instant('labelPartition')} ${partition.partitionNumber}`,
value: Number((partition.size / 1024).toFixed(2))
@ -172,7 +175,8 @@ export class ClientDetailsComponent {
total,
used,
percentage: total > 0 ? Math.round((used / total) * 100) : 0,
partitions
partitions,
diskType
};
});
@ -207,6 +211,10 @@ export class ClientDetailsComponent {
});
}
getPartitionBadges(diskNumber: number, partitionNumber: number): PluginBadge[] {
return this.pluginService.getPartitionBadges(this.clientData, diskNumber, partitionNumber);
}
onNoClick(): void {
this.dialog.closeAll();
}

View File

@ -3,57 +3,59 @@
<div class="inputs-container">
<div class="mat-dialog-content" [ngClass]="{'loading': loading}">
<form class="client-form grid-form" *ngIf="!loading">
<mat-form-field class="form-field">
<mat-label>{{ 'labelOrganizationalUnit' | translate }}</mat-label>
<mat-select (selectionChange)="setOrganizationalUnit($event)" [value]="organizationalUnit">
<mat-select-trigger>
{{ getSelectedParentName() }}
</mat-select-trigger>
<mat-option *ngFor="let unit of parentUnitsWithPaths" [value]="unit.id">
<div class="unit-name">{{ unit.name }}</div>
<div style="font-size: smaller; color: gray;">{{ unit.path }}</div>
</mat-option>
</mat-select>
</mat-form-field>
</form>
@if (!loading) {
<form class="client-form grid-form">
<app-ou-select
class="form-field"
[ngModel]="organizationalUnit"
[units]="parentUnitsWithPaths"
[label]="'labelOrganizationalUnit'"
(selectionChange)="setOrganizationalUnit($event)">
</app-ou-select>
</form>
}
<div>
<div class="upload-container">
<button class="action-button" (click)="fileInput.click()">{{ 'buttonUploadFile' | translate }}</button>
<input #fileInput type="file" (change)="onFileUpload($event)" accept="*" hidden>
<p>{{ 'orAddThemManually' | translate }}</p>
<div *ngIf="showTextarea">
<textarea #textarea matInput
[placeholder]="'dhcpHostExample' | translate"
@if (showTextarea) {
<div>
<textarea #textarea matInput
[placeholder]="'dhcpHostExample' | translate"
rows="20" cols="100"></textarea>
<button class="action-button" style="margin-top: 0.5em;"
<button class="action-button" style="margin-top: 0.5em;"
(click)="onTextarea(textarea.value)">{{ 'buttonPreview' | translate }}</button>
</div>
}
</div>
@if (uploadedClients.length > 0) {
<h4>{{ 'importedClientsZero' | translate }}</h4>
}
@if (uploadedClients.length > 0) {
<div class="scrollable-table">
@if (uploadedClients.length > 0) {
<table mat-table [dataSource]="uploadedClients" class="mat-elevation-z8">
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef> {{ 'labelRoleName' | translate }}</th>
<td mat-cell *matCellDef="let client"> {{ client.name }} </td>
</ng-container>
<ng-container matColumnDef="ip">
<th mat-header-cell *matHeaderCellDef> {{ 'labelIpCaps' | translate }}</th>
<td mat-cell *matCellDef="let client"> {{ client.ip }} </td>
</ng-container>
<ng-container matColumnDef="mac">
<th mat-header-cell *matHeaderCellDef> {{ 'labelMac' | translate }}</th>
<td mat-cell *matCellDef="let client"> {{ client.mac }} </td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
}
</div>
</div>
<h4 *ngIf="uploadedClients.length > 0">{{ 'importedClientsZero' | translate }}</h4>
<div *ngIf="uploadedClients.length > 0" class="scrollable-table">
<table mat-table [dataSource]="uploadedClients" class="mat-elevation-z8" *ngIf="uploadedClients.length > 0">
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef> {{ 'labelRoleName' | translate }}</th>
<td mat-cell *matCellDef="let client"> {{ client.name }} </td>
</ng-container>
<ng-container matColumnDef="ip">
<th mat-header-cell *matHeaderCellDef> {{ 'labelIpCaps' | translate }}</th>
<td mat-cell *matCellDef="let client"> {{ client.ip }} </td>
</ng-container>
<ng-container matColumnDef="mac">
<th mat-header-cell *matHeaderCellDef> {{ 'labelMac' | translate }}</th>
<td mat-cell *matCellDef="let client"> {{ client.mac }} </td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns;"></tr>
</table>
</div>
}
</div>
</div>
</div>

View File

@ -65,10 +65,7 @@ export class CreateMultipleClientComponent implements OnInit {
);
}
getSelectedParentName(): string | undefined {
const parentId = this.organizationalUnit;
return this.parentUnitsWithPaths.find(unit => unit.id === parentId)?.name;
}
setOrganizationalUnit(organizationalUnit: any): void {
this.organizationalUnit = organizationalUnit.value;
@ -133,9 +130,15 @@ export class CreateMultipleClientComponent implements OnInit {
let successCount = 0;
let errorMessages: string[] = [];
let ou = this.organizationalUnit;
if (typeof ou === 'number' || (typeof ou === 'string' && !ou.includes('/'))) {
const selectedUnit = this.parentUnits.find((u: any) => u.id === Number(ou));
ou = selectedUnit ? (selectedUnit['@id'] || `/organizational-units/${selectedUnit.uuid}`) : ou;
}
this.uploadedClients.forEach(client => {
const formData = {
organizationalUnit: this.organizationalUnit,
organizationalUnit: ou,
name: client.name || null,
mac: client.mac || null,
ip: client.ip || null,

View File

@ -83,4 +83,22 @@ mat-option .unit-path {
display: grid;
grid-template-columns: repeat(3, 1fr);
column-gap: 20px;
}
.hardware-profile-box {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 0 12px 0;
}
.hardware-profile-box .form-field {
flex: 1;
min-width: 0;
width: auto;
}
.hardware-profile-link {
margin-left: auto;
color: #3f51b5;
}

View File

@ -1,155 +1,155 @@
<mat-dialog-content class="create-client-container">
<h1 mat-dialog-title>{{ dialogTitle | translate }}</h1>
<div [ngClass]="{'loading': loading}">
<mat-spinner class="loading-spinner" *ngIf="loading"></mat-spinner>
<form *ngIf="clientForm && !loading" [formGroup]="clientForm" class="client-form grid-form">
<h1 mat-dialog-title>{{ dialogTitle | translate }}</h1>
<div [ngClass]="{'loading': loading}">
@if (loading) {
<mat-spinner class="loading-spinner"></mat-spinner>
}
@if (clientForm && !loading) {
<form [formGroup]="clientForm" class="client-form grid-form">
<app-ou-select
class="form-field"
formControlName="organizationalUnit"
[units]="parentUnitsWithPaths"
[label]="'labelOrganizationalUnit'"
(selectionChange)="onParentChange($event)">
</app-ou-select>
<mat-form-field class="form-field">
<mat-label>{{ 'labelName' | translate }}</mat-label>
<input matInput formControlName="name" required>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'labelSerialNumber' | translate }}</mat-label>
<input matInput formControlName="serialNumber">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'labelNetiface' | translate }}</mat-label>
<mat-select formControlName="netiface">
@for (type of netifaceTypes; track type.value) {
<mat-option [value]="type.value">{{ type.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'labelMac' | translate }}</mat-label>
<input matInput formControlName="mac" required>
<mat-error>{{ 'macError' | translate }}</mat-error>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'labelIp' | translate }}</mat-label>
<input matInput formControlName="ip" required>
<mat-error>{{ 'ipError' | translate }}</mat-error>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'labelRepository' | translate }}</mat-label>
<mat-select formControlName="repository">
@for (repository of repositories; track repository['@id']) {
<mat-option [value]="repository['@id']">{{ repository.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-checkbox formControlName="maintenance">
{{ 'maintenance' | translate }}
</mat-checkbox>
<!-- ===== Agrupación: Configuración del menú ===== -->
<fieldset class="fieldset-group">
<legend class="fieldset-legend">{{ 'menuConfiguration' | translate }}</legend>
<div class="fieldset-grid-2">
<mat-form-field class="form-field">
<mat-label>{{ 'labelOrganizationalUnit' | translate }}</mat-label>
<mat-select formControlName="organizationalUnit" (selectionChange)="onParentChange($event)">
<mat-select-trigger>
{{ getSelectedParentName() }}
</mat-select-trigger>
@for (unit of parentUnitsWithPaths; track unit.id) {
<mat-option [value]="unit.id">
<div class="unit-name">{{ unit.name }}</div>
<div style="font-size: smaller; color: gray;">{{ unit.path }}</div>
</mat-option>
}
<mat-label>{{ 'labelMenu' | translate }}</mat-label>
<mat-select formControlName="menu">
@for (menu of menus; track menu['@id']) {
<mat-option [value]="menu['@id']">{{ menu.name }}</mat-option>
}
</mat-select>
<mat-error>{{ 'menuError' | translate }}</mat-error>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'labelResolution' | translate }}</mat-label>
<mat-select formControlName="resolution" required>
<mat-option value="769">{{ 'resolution640x480x8' | translate }}</mat-option>
<mat-option value="771">{{ 'resolution800x600x8' | translate }}</mat-option>
<mat-option value="773">{{ 'resolution1024x768x8' | translate }}</mat-option>
<mat-option value="775">{{ 'resolution1280x1024x8' | translate }}</mat-option>
<mat-option value="784">{{ 'resolution640x480x16' | translate }}</mat-option>
<mat-option value="787">{{ 'resolution800x600x16' | translate }}</mat-option>
<mat-option value="791">{{ 'resolution1024x768x16' | translate }}</mat-option>
<mat-option value="794">{{ 'resolution1280x1024x16' | translate }}</mat-option>
<mat-option value="785">{{ 'resolution640x480x24' | translate }}</mat-option>
<mat-option value="788">{{ 'resolution800x600x24' | translate }}</mat-option>
<mat-option value="792">{{ 'resolution1024x768x24' | translate }}</mat-option>
<mat-option value="795">{{ 'resolution1280x1024x24' | translate }}</mat-option>
</mat-select>
<mat-error>{{ 'resolutionError' | translate }}</mat-error>
</mat-form-field>
</div>
</fieldset>
<!-- ===== Agrupación: Configuración de arranque ===== -->
<fieldset class="fieldset-group">
<legend class="fieldset-legend">{{ 'bootConfiguration' | translate }}</legend>
<div class="fieldset-grid-3">
<mat-form-field class="form-field">
<mat-label>{{ 'bootServer' | translate }}</mat-label>
<mat-select formControlName="bootServer" (selectionChange)="onBootServerChange($event)">
@for (server of bootServers; track server['@id']) {
<mat-option [value]="server['@id']">{{ server.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'labelOgLive' | translate }}</mat-label>
<mat-select formControlName="ogLive">
@for (ogLive of ogLives; track ogLive['@id']) {
<mat-option [value]="ogLive['@id']">{{ ogLive.name }}</mat-option>
}
@if (ogLives.length === 0 && clientForm.get('bootServer')?.value) {
<mat-option disabled>{{ 'noOgLivesInServer' | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'labelTemplate' | translate }}</mat-label>
<mat-select formControlName="pxeTemplate">
@for (template of templates; track template['@id']) {
<mat-option [value]="template['@id']">{{ template.name }}</mat-option>
}
@if (templates.length === 0 && clientForm.get('bootServer')?.value) {
<mat-option disabled>{{ 'noTemplatesInServer' | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</fieldset>
@if (isEditMode) {
<fieldset class="fieldset-group">
<legend class="fieldset-legend">{{ 'labelHardwareProfile' | translate }}</legend>
<div class="hardware-profile-box">
<mat-form-field class="form-field">
<mat-select formControlName="hardwareProfile">
<mat-option [value]="null">{{ 'noHardwareProfileAssigned' | translate }}</mat-option>
@for (profile of hardwareProfiles; track profile['@id']) {
<mat-option [value]="profile['@id']">{{ profile.description }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'labelName' | translate }}</mat-label>
<input matInput formControlName="name" required>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'labelSerialNumber' | translate }}</mat-label>
<input matInput formControlName="serialNumber">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'labelNetiface' | translate }}</mat-label>
<mat-select formControlName="netiface">
@for (type of netifaceTypes; track type.value) {
<mat-option [value]="type.value">{{ type.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'labelMac' | translate }}</mat-label>
<input matInput formControlName="mac" required>
<mat-error>{{ 'macError' | translate }}</mat-error>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'labelIp' | translate }}</mat-label>
<input matInput formControlName="ip" required>
<mat-error>{{ 'ipError' | translate }}</mat-error>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'labelRepository' | translate }}</mat-label>
<mat-select formControlName="repository">
@for (repository of repositories; track repository['@id']) {
<mat-option [value]="repository['@id']">{{ repository.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-checkbox formControlName="maintenance">
{{ 'maintenance' | translate }}
</mat-checkbox>
<!-- ===== Agrupación: Configuración del menú ===== -->
<fieldset class="fieldset-group">
<legend class="fieldset-legend">{{ 'menuConfiguration' | translate }}</legend>
<div class="fieldset-grid-2">
<mat-form-field class="form-field">
<mat-label>{{ 'labelMenu' | translate }}</mat-label>
<mat-select formControlName="menu">
@for (menu of menus; track menu['@id']) {
<mat-option [value]="menu['@id']">{{ menu.name }}</mat-option>
}
</mat-select>
<mat-error>{{ 'menuError' | translate }}</mat-error>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'labelResolution' | translate }}</mat-label>
<mat-select formControlName="resolution" required>
<mat-option value="769">{{ 'resolution640x480x8' | translate }}</mat-option>
<mat-option value="771">{{ 'resolution800x600x8' | translate }}</mat-option>
<mat-option value="773">{{ 'resolution1024x768x8' | translate }}</mat-option>
<mat-option value="775">{{ 'resolution1280x1024x8' | translate }}</mat-option>
<mat-option value="784">{{ 'resolution640x480x16' | translate }}</mat-option>
<mat-option value="787">{{ 'resolution800x600x16' | translate }}</mat-option>
<mat-option value="791">{{ 'resolution1024x768x16' | translate }}</mat-option>
<mat-option value="794">{{ 'resolution1280x1024x16' | translate }}</mat-option>
<mat-option value="785">{{ 'resolution640x480x24' | translate }}</mat-option>
<mat-option value="788">{{ 'resolution800x600x24' | translate }}</mat-option>
<mat-option value="792">{{ 'resolution1024x768x24' | translate }}</mat-option>
<mat-option value="795">{{ 'resolution1280x1024x24' | translate }}</mat-option>
</mat-select>
<mat-error>{{ 'resolutionError' | translate }}</mat-error>
</mat-form-field>
</div>
</fieldset>
<!-- ===== Agrupación: Configuración de arranque ===== -->
<fieldset class="fieldset-group">
<legend class="fieldset-legend">{{ 'bootConfiguration' | translate }}</legend>
<div class="fieldset-grid-3">
<mat-form-field class="form-field">
<mat-label>{{ 'bootServer' | translate }}</mat-label>
<mat-select formControlName="bootServer" (selectionChange)="onBootServerChange($event)">
@for (server of bootServers; track server['@id']) {
<mat-option [value]="server['@id']">{{ server.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'labelOgLive' | translate }}</mat-label>
<mat-select formControlName="ogLive">
@for (ogLive of ogLives; track ogLive['@id']) {
<mat-option [value]="ogLive['@id']">{{ ogLive.name }}</mat-option>
}
@if (ogLives.length === 0 && clientForm.get('bootServer')?.value) {
<mat-option disabled>{{ 'noOgLivesInServer' | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'labelTemplate' | translate }}</mat-label>
<mat-select formControlName="pxeTemplate">
@for (template of templates; track template['@id']) {
<mat-option [value]="template['@id']">{{ template.name }}</mat-option>
}
@if (templates.length === 0 && clientForm.get('bootServer')?.value) {
<mat-option disabled>{{ 'noTemplatesInServer' | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</fieldset>
<mat-form-field class="form-field full-width">
<mat-label>{{ 'labelNotes' | translate }}</mat-label>
<textarea matInput formControlName="notes" rows="4"
[placeholder]="'notesPlaceholder' | translate"></textarea>
</mat-form-field>
</form>
</div>
</mat-form-field>
<button type="button" mat-icon-button class="hardware-profile-link" (click)="goToHardwareProfiles()"
[matTooltip]="'manageHardwareProfiles' | translate">
<mat-icon>settings</mat-icon>
</button>
</div>
</fieldset>
}
<mat-form-field class="form-field full-width">
<mat-label>{{ 'labelNotes' | translate }}</mat-label>
<textarea matInput formControlName="notes" rows="4"
[placeholder]="'notesPlaceholder' | translate"></textarea>
</mat-form-field>
</form>
}
</div>
</mat-dialog-content>
<mat-dialog-actions class="action-container">
<button class="ordinary-button" (click)="onNoClick()">{{ 'buttonCancel' | translate }}</button>
<button class="submit-button" [disabled]="!clientForm.valid" (click)="onSubmit()">
{{ isEditMode ? 'Guardar' : 'Crear' }}
</button>
<button class="ordinary-button" (click)="onNoClick()">{{ 'buttonCancel' | translate }}</button>
<button class="submit-button" [disabled]="!clientForm.valid" (click)="onSubmit()">
{{ isEditMode ? 'Guardar' : 'Crear' }}
</button>
</mat-dialog-actions>

View File

@ -5,6 +5,7 @@ import { Component, Inject, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { ToastrService } from 'ngx-toastr';
import { Router } from '@angular/router';
import { DataService } from '../../../services/data.service';
import { ConfigService } from '@services/config.service';
@ -24,6 +25,7 @@ export class ManageClientComponent implements OnInit {
templates: any[] = [];
repositories: any[] = [];
bootServers: any[] = [];
hardwareProfiles: any[] = [];
loading: boolean = false;
displayedColumns: string[] = ['name', 'ip'];
dialogTitle: string;
@ -41,6 +43,7 @@ export class ManageClientComponent implements OnInit {
private configService: ConfigService,
private toastService: ToastrService,
private dataService: DataService,
private router: Router,
@Inject(MAT_DIALOG_DATA) public data: any
) {
this.baseUrl = this.configService.apiUrl;
@ -55,7 +58,8 @@ export class ManageClientComponent implements OnInit {
this.loadParentUnits(),
this.loadBootServers(),
this.loadRepositories(),
this.loadMenus()
this.loadMenus(),
this.loadHardwareProfiles()
];
Promise.all(observables).then(() => {
@ -91,6 +95,7 @@ export class ManageClientComponent implements OnInit {
ogLive: [null],
repository: [null],
menu: [null],
hardwareProfile: [null],
maintenance: [false],
resolution: [null],
notes: ['']
@ -132,10 +137,6 @@ export class ManageClientComponent implements OnInit {
});
}
getSelectedParentName(): string | undefined {
const parentId = this.clientForm.get('organizationalUnit')?.value;
return this.parentUnitsWithPaths.find(unit => unit.id === parentId)?.name;
}
loadOgLivesByServer(bootServerIri: string): Promise<void> {
// Extract UUID from IRI (e.g. "/boot-servers/xxxx-xxx" -> "xxxx-xxx")
@ -222,6 +223,24 @@ export class ManageClientComponent implements OnInit {
});
}
loadHardwareProfiles(): Promise<void> {
return new Promise((resolve, reject) => {
const url = `${this.baseUrl}/hardware-profiles?page=1&itemsPerPage=10000`;
this.http.get<any>(url).subscribe(
response => {
this.hardwareProfiles = response['hydra:member'];
resolve();
},
error => {
console.error(this.translate.instant('errorFetchingHardwareProfiles'), error);
this.hardwareProfiles = [];
resolve();
}
);
});
}
loadRepositories(): Promise<void> {
return new Promise((resolve, reject) => {
const url = `${this.baseUrl}/image-repositories?page=1&itemsPerPage=10000`;
@ -294,6 +313,7 @@ export class ManageClientComponent implements OnInit {
bootServer: bootServerIri,
repository: data.repository ? data.repository['@id'] : null,
menu: data.menu ? data.menu['@id'] : null,
hardwareProfile: data.hardwareProfile ? data.hardwareProfile['@id'] : null,
maintenance: data.maintenance,
resolution: data.resolution,
notes: data.notes || ''
@ -325,7 +345,14 @@ export class ManageClientComponent implements OnInit {
onSubmit(): void {
if (this.clientForm.valid) {
const formData = this.clientForm.value;
const formData = { ...this.clientForm.value };
let ou = formData.organizationalUnit;
if (typeof ou === 'number' || (typeof ou === 'string' && !ou.includes('/'))) {
const selectedUnit = this.parentUnits.find((u: any) => u.id === Number(ou));
ou = selectedUnit ? (selectedUnit['@id'] || `/organizational-units/${selectedUnit.uuid}`) : ou;
}
formData.organizationalUnit = ou;
if (this.isEditMode) {
const putUrl = `${this.baseUrl}/clients/${this.data.uuid}`;
@ -364,4 +391,9 @@ export class ManageClientComponent implements OnInit {
onNoClick(): void {
this.dialogRef.close({ success: false });
}
goToHardwareProfiles(): void {
this.dialogRef.close({ success: false });
this.router.navigate(['/hardware-profiles']);
}
}

View File

@ -32,6 +32,19 @@
font-weight: bold;
}
.badge-disk-type {
display: inline-flex;
align-items: center;
padding: 2px 8px;
font-size: 0.75rem;
font-weight: 600;
color: #1976d2;
background-color: #e3f2fd;
border-radius: 12px;
margin-left: 8px;
vertical-align: middle;
}
.disk-card-layout {
display: flex;
align-items: stretch;

View File

@ -3,7 +3,11 @@
<div class="disk-card-wrapper">
<mat-card class="disk-mat-card mat-elevation-z4">
<div class="disk-card-header">
<h3 class="disk-card-title">Disco {{ disk.diskNumber }}</h3>
<h3 class="disk-card-title">Disco {{ disk.diskNumber }}
@if (disk.diskType) {
<span class="badge-disk-type">{{ disk.diskType }}</span>
}
</h3>
</div>
<div class="disk-card-layout">

View File

@ -43,9 +43,12 @@ export class DiskPartitionSelectorComponent implements OnChanges {
for (const diskNum in grouped) {
if (grouped.hasOwnProperty(diskNum)) {
const diskPartitions = grouped[diskNum];
const partitionZero = diskPartitions.find((p: any) => p.partitionNumber === 0);
this.diskUsageData.push({
diskNumber: diskNum,
partitions: grouped[diskNum]
partitions: diskPartitions.filter((p: any) => p.partitionNumber !== 0),
diskType: partitionZero ? partitionZero.diskType : undefined
});
}
}

View File

@ -1,258 +1,279 @@
<mat-dialog-content class="create-ou-container">
<h1 mat-dialog-title>{{ isEditMode ? ('edit' | translate) : ('createButton' | translate) }} {{
'labelOrganizationalUnit' | translate }}</h1>
<div [ngClass]="{'loading': loading}">
<!-- Paso 1: General -->
<mat-spinner class="loading-spinner" *ngIf="loading"></mat-spinner>
<span *ngIf="!loading" class="step-title">{{ 'labelGeneralTab' | translate }}</span>
<form *ngIf="generalFormGroup && !loading" [formGroup]="generalFormGroup" class="grid-form">
<mat-form-field class="form-field" appearance="fill">
<mat-label>{{ 'labelType' | translate }}</mat-label>
<mat-select formControlName="type" required>
<mat-option *ngFor="let type of filteredTypes" [value]="type">
{{ typeTranslations[type] }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="form-field" appearance="fill">
<mat-label>{{ 'labelRoleName' | translate }}</mat-label>
<input matInput formControlName="name" required>
</mat-form-field>
<mat-form-field class="form-field" appearance="fill">
<mat-label>{{ 'labelCreateOrgUnitparent' | translate }}</mat-label>
<mat-select formControlName="parent" (selectionChange)="onParentChange($event)">
<mat-select-trigger>
{{ getSelectedParentName() }}
</mat-select-trigger>
<mat-option *ngFor="let unit of parentUnitsWithPaths" [value]="unit.id">
<div>{{ unit.name }}</div>
<div style="font-size: smaller; color: gray;">{{ unit.path }}</div>
</mat-option>
</mat-select>
</mat-form-field>
<h1 mat-dialog-title>{{ isEditMode ? ('edit' | translate) : ('createButton' | translate) }} {{
'labelOrganizationalUnit' | translate }}</h1>
<div [ngClass]="{'loading': loading}">
<!-- Paso 1: General -->
@if (loading) {
<mat-spinner class="loading-spinner"></mat-spinner>
}
@if (!loading) {
<span class="step-title">{{ 'labelGeneralTab' | translate }}</span>
}
@if (generalFormGroup && !loading) {
<form [formGroup]="generalFormGroup" class="grid-form">
<mat-form-field class="form-field" appearance="fill">
<mat-label>{{ 'labelType' | translate }}</mat-label>
<mat-select formControlName="type" required>
@for (type of filteredTypes; track type) {
<mat-option [value]="type">
{{ typeTranslations[type] }}
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="form-field" appearance="fill">
<mat-label>{{ 'labelRoleName' | translate }}</mat-label>
<input matInput formControlName="name" required>
</mat-form-field>
<app-ou-select
class="form-field"
formControlName="parent"
[units]="parentUnitsWithPaths"
[label]="'labelCreateOrgUnitparent'"
(selectionChange)="onParentChange($event)">
</app-ou-select>
<mat-form-field class="form-field description-form-field" appearance="fill">
<mat-label>{{ 'labelDescription' | translate }}</mat-label>
<textarea matInput formControlName="description"></textarea>
</mat-form-field>
<mat-checkbox formControlName="excludeParentChanges">
{{ 'excludeParentChanges' | translate }}
</mat-checkbox>
</form>
}
<mat-form-field class="form-field description-form-field" appearance="fill">
<mat-label>{{ 'labelDescription' | translate }}</mat-label>
<textarea matInput formControlName="description"></textarea>
</mat-form-field>
<!-- Paso 2: Información del Aula -->
@if (generalFormGroup.value.type === 'classroom' && !loading) {
<span class="step-title">{{ 'labelClassroomInfoStep' | translate }}</span>
<form class="grid-form" [formGroup]="classroomInfoFormGroup">
<mat-form-field class="form-field">
<mat-label>{{ 'labelLocation' | translate }}</mat-label>
<input matInput formControlName="location">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'labelCapacity' | translate }}</mat-label>
<input matInput formControlName="capacity" type="number" min="0">
@if (classroomInfoFormGroup.get('capacity')?.hasError('min')) {
<mat-error>
{{ 'capacityWarning' | translate }}
</mat-error>
}
</mat-form-field>
<mat-form-field class="form-field" appearance="fill" style="grid-column: span 1;">
<mat-label>{{ 'labelAssociatedCalendar' | translate }}</mat-label>
<mat-select formControlName="remoteCalendar" (selectionChange)="onCalendarChange($event)">
@for (calendar of calendars; track calendar) {
<mat-option [value]="calendar['@id']">
{{ calendar.name }}
</mat-option>
}
</mat-select>
</mat-form-field>
<div class="projector-board-field">
<mat-slide-toggle formControlName="projector">{{ 'projectorAlt' | translate }}</mat-slide-toggle>
<mat-slide-toggle formControlName="board">{{ 'boardToggle' | translate }}</mat-slide-toggle>
<mat-slide-toggle formControlName="remotePc">{{ 'remotePcLabel' | translate }}</mat-slide-toggle>
@if (classroomInfoFormGroup.get('remotePc')?.value) {
<mat-slide-toggle formControlName="remotePcRebootOnUnreserve">{{ 'remotePcRebootOnUnreserveLabel' | translate }}</mat-slide-toggle>
}
</div>
</form>
}
<mat-checkbox formControlName="excludeParentChanges">
{{ 'excludeParentChanges' | translate }}
</mat-checkbox>
</form>
<!-- Paso 2: Información del Aula -->
@if (generalFormGroup.value.type === 'classroom' && !loading) {
<span class="step-title">{{ 'labelClassroomInfoStep' | translate }}</span>
<form class="grid-form" [formGroup]="classroomInfoFormGroup">
<!-- Paso 3: Configuración de Red -->
@if (!loading) {
<span class="step-title">{{ 'labelNetworkSettingsStep' | translate }}</span>
}
@if (networkSettingsFormGroup && !loading) {
<form [formGroup]="networkSettingsFormGroup" class="grid-form">
<!-- ===== Agrupación: Configuración de arranque ===== -->
<fieldset class="fieldset-group">
<legend class="fieldset-legend">{{ 'bootConfiguration' | translate }}</legend>
<div class="fieldset-grid-3">
<mat-form-field class="form-field">
<mat-label>{{ 'labelLocation' | translate }}</mat-label>
<input matInput formControlName="location">
<mat-label>{{ 'bootServer' | translate }}</mat-label>
<mat-select formControlName="bootServer" (selectionChange)="onBootServerChange($event)">
@for (server of bootServers; track server['@id']) {
<mat-option [value]="server['@id']">{{ server.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'labelCapacity' | translate }}</mat-label>
<input matInput formControlName="capacity" type="number" min="0">
<mat-error *ngIf="classroomInfoFormGroup.get('capacity')?.hasError('min')">
{{ 'capacityWarning' | translate }}
</mat-error>
</mat-form-field>
<mat-form-field class="form-field" appearance="fill" style="grid-column: span 1;">
<mat-label>{{ 'labelAssociatedCalendar' | translate }}</mat-label>
<mat-select formControlName="remoteCalendar" (selectionChange)="onCalendarChange($event)">
<mat-option *ngFor="let calendar of calendars" [value]="calendar['@id']">
{{ calendar.name }}
</mat-option>
</mat-select>
</mat-form-field>
<div class="projector-board-field">
<mat-slide-toggle formControlName="projector">{{ 'projectorAlt' | translate }}</mat-slide-toggle>
<mat-slide-toggle formControlName="board">{{ 'boardToggle' | translate }}</mat-slide-toggle>
<mat-slide-toggle formControlName="remotePc">{{ 'remotePcLabel' | translate }}</mat-slide-toggle>
</div>
</form>
}
<!-- Paso 3: Configuración de Red -->
<span *ngIf="!loading" class="step-title">{{ 'labelNetworkSettingsStep' | translate }}</span>
<form *ngIf="networkSettingsFormGroup && !loading" [formGroup]="networkSettingsFormGroup" class="grid-form">
<!-- ===== Agrupación: Configuración de arranque ===== -->
<fieldset class="fieldset-group">
<legend class="fieldset-legend">{{ 'bootConfiguration' | translate }}</legend>
<div class="fieldset-grid-3">
<mat-form-field class="form-field">
<mat-label>{{ 'bootServer' | translate }}</mat-label>
<mat-select formControlName="bootServer" (selectionChange)="onBootServerChange($event)">
@for (server of bootServers; track server['@id']) {
<mat-option [value]="server['@id']">{{ server.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'ogLive' | translate }}</mat-label>
<mat-select formControlName="ogLive" (selectionChange)="onOgLiveChange($event)">
@for (oglive of ogLives; track oglive['@id']) {
<mat-option [value]="oglive['@id']">{{ oglive.name }}</mat-option>
}
@if (ogLives.length === 0 && networkSettingsFormGroup.get('bootServer')?.value) {
<mat-option disabled>{{ 'noOgLivesInServer' | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'labelTemplate' | translate }}</mat-label>
<mat-select formControlName="pxeTemplate" (selectionChange)="onPxeTemplateChange($event)">
@for (template of pxeTemplates; track template['@id']) {
<mat-option [value]="template['@id']">{{ template.name }}</mat-option>
}
@if (pxeTemplates.length === 0 && networkSettingsFormGroup.get('bootServer')?.value) {
<mat-option disabled>{{ 'noTemplatesInServer' | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</fieldset>
<mat-form-field class="form-field">
<mat-label>{{ 'labelRepository' | translate }}</mat-label>
<mat-select formControlName="repository" (selectionChange)="onRepositoryChange($event)">
<mat-option *ngFor="let repository of repositories" [value]="repository['@id']">
{{ repository.name }}
</mat-option>
</mat-select>
<mat-label>{{ 'ogLive' | translate }}</mat-label>
<mat-select formControlName="ogLive" (selectionChange)="onOgLiveChange($event)">
@for (oglive of ogLives; track oglive['@id']) {
<mat-option [value]="oglive['@id']">{{ oglive.name }}</mat-option>
}
@if (ogLives.length === 0 && networkSettingsFormGroup.get('bootServer')?.value) {
<mat-option disabled>{{ 'noOgLivesInServer' | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'labelProxy' | translate }}</mat-label>
<input matInput formControlName="proxy">
<mat-label>{{ 'labelTemplate' | translate }}</mat-label>
<mat-select formControlName="pxeTemplate" (selectionChange)="onPxeTemplateChange($event)">
@for (template of pxeTemplates; track template['@id']) {
<mat-option [value]="template['@id']">{{ template.name }}</mat-option>
}
@if (pxeTemplates.length === 0 && networkSettingsFormGroup.get('bootServer')?.value) {
<mat-option disabled>{{ 'noTemplatesInServer' | translate }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</fieldset>
<mat-form-field class="form-field">
<mat-label>{{ 'labelRepository' | translate }}</mat-label>
<mat-select formControlName="repository" (selectionChange)="onRepositoryChange($event)">
@for (repository of repositories; track repository) {
<mat-option [value]="repository['@id']">
{{ repository.name }}
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'labelProxy' | translate }}</mat-label>
<input matInput formControlName="proxy">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'labelDns' | translate }}</mat-label>
<input matInput formControlName="dns">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'labelNetmask' | translate }}</mat-label>
<input matInput formControlName="netmask">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label i18n="@@netiface-label">{{ 'labelNetiface' | translate }}</mat-label>
<mat-select formControlName="netiface">
@for (type of netifaceTypes; track type) {
<mat-option [value]="type.value">
{{ type.name }}
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'labelRouter' | translate }}</mat-label>
<input matInput formControlName="router">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'labelNtp' | translate }}</mat-label>
<input matInput formControlName="ntp">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'labelHardwareProfile' | translate }}</mat-label>
<mat-select formControlName="hardwareProfile">
@for (unit of hardwareProfiles; track unit) {
<mat-option [value]="unit['@id']">{{ unit.description
}}</mat-option>
}
</mat-select>
</mat-form-field>
<!-- ===== Agrupación: Configuración de multicast ===== -->
<fieldset class="fieldset-group">
<legend class="fieldset-legend">{{ 'multicastConfiguration' | translate }}</legend>
<div class="fieldset-grid-2">
<mat-form-field class="form-field">
<mat-label>{{ 'labelMcastIp' | translate }}</mat-label>
<input matInput formControlName="mcastIp">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'labelDns' | translate }}</mat-label>
<input matInput formControlName="dns">
<mat-label>{{ 'labelMcastSpeed' | translate }}</mat-label>
<input matInput formControlName="mcastSpeed" type="number">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'labelNetmask' | translate }}</mat-label>
<input matInput formControlName="netmask">
<mat-label>{{ 'labelMcastPort' | translate }}</mat-label>
<input matInput formControlName="mcastPort" type="number">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label i18n="@@netiface-label">{{ 'labelNetiface' | translate }}</mat-label>
<mat-select formControlName="netiface">
<mat-option *ngFor="let type of netifaceTypes" [value]="type.value">
{{ type.name }}
</mat-option>
</mat-select>
<mat-label>{{ 'labelMcastMode' | translate }}</mat-label>
<mat-select formControlName="mcastMode">
@for (option of multicastModeOptions; track option) {
<mat-option [value]="option.value">
{{ option.name }}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</fieldset>
<!-- ===== Agrupación: Configuración de p2p ===== -->
<fieldset class="fieldset-group">
<legend class="fieldset-legend">{{ 'p2pConfiguration' | translate }}</legend>
<div class="fieldset-grid-2">
<mat-form-field class="form-field">
<mat-label>{{ 'labelP2pMode' | translate }}</mat-label>
<mat-select formControlName="p2pMode">
@for (option of p2pModeOptions; track option) {
<mat-option [value]="option.value">
{{ option.name }}
</mat-option>
}
</mat-select>
</mat-form-field>
@if (networkSettingsFormGroup.get('p2pMode')?.value === 'seeder') {
<mat-form-field class="form-field">
<mat-label>{{ 'labelP2pTime' | translate }} (minutos)</mat-label>
<input matInput formControlName="p2pTime" type="number">
</mat-form-field>
}
</div>
</fieldset>
<!-- ===== Agrupación: Configuración del menú ===== -->
<fieldset class="fieldset-group">
<legend class="fieldset-legend">{{ 'menuConfiguration' | translate }}</legend>
<div class="fieldset-grid-2">
<mat-form-field class="form-field">
<mat-label>{{ 'labelMenu' | translate }}</mat-label>
<mat-select formControlName="menu" (selectionChange)="onMenuChange($event)">
@for (menu of menus; track menu) {
<mat-option [value]="menu['@id']">
{{ menu.name }}
</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'labelRouter' | translate }}</mat-label>
<input matInput formControlName="router">
<mat-label>{{ 'labelResolution' | translate }}</mat-label>
<mat-select formControlName="resolution">
<mat-option value="769">{{ 'resolution640x480x8' | translate }}</mat-option>
<mat-option value="771">{{ 'resolution800x600x8' | translate }}</mat-option>
<mat-option value="773">{{ 'resolution1024x768x8' | translate }}</mat-option>
<mat-option value="775">{{ 'resolution1280x1024x8' | translate }}</mat-option>
<mat-option value="784">{{ 'resolution640x480x16' | translate }}</mat-option>
<mat-option value="787">{{ 'resolution800x600x16' | translate }}</mat-option>
<mat-option value="791">{{ 'resolution1024x768x16' | translate }}</mat-option>
<mat-option value="794">{{ 'resolution1280x1024x16' | translate }}</mat-option>
<mat-option value="785">{{ 'resolution640x480x24' | translate }}</mat-option>
<mat-option value="788">{{ 'resolution800x600x24' | translate }}</mat-option>
<mat-option value="792">{{ 'resolution1024x768x24' | translate }}</mat-option>
<mat-option value="795">{{ 'resolution1280x1024x24' | translate }}</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'labelNtp' | translate }}</mat-label>
<input matInput formControlName="ntp">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'labelHardwareProfile' | translate }}</mat-label>
<mat-select formControlName="hardwareProfile">
<mat-option *ngFor="let unit of hardwareProfiles" [value]="unit['@id']">{{ unit.description
}}</mat-option>
</mat-select>
</mat-form-field>
<!-- ===== Agrupación: Configuración de multicast ===== -->
<fieldset class="fieldset-group">
<legend class="fieldset-legend">{{ 'multicastConfiguration' | translate }}</legend>
<div class="fieldset-grid-2">
<mat-form-field class="form-field">
<mat-label>{{ 'labelMcastIp' | translate }}</mat-label>
<input matInput formControlName="mcastIp">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'labelMcastSpeed' | translate }}</mat-label>
<input matInput formControlName="mcastSpeed" type="number">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'labelMcastPort' | translate }}</mat-label>
<input matInput formControlName="mcastPort" type="number">
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'labelMcastMode' | translate }}</mat-label>
<mat-select formControlName="mcastMode">
<mat-option *ngFor="let option of multicastModeOptions" [value]="option.value">
{{ option.name }}
</mat-option>
</mat-select>
</mat-form-field>
</div>
</fieldset>
</div>
</fieldset>
</form>
}
<!-- ===== Agrupación: Configuración de p2p ===== -->
<fieldset class="fieldset-group">
<legend class="fieldset-legend">{{ 'p2pConfiguration' | translate }}</legend>
<div class="fieldset-grid-2">
<mat-form-field class="form-field">
<mat-label>{{ 'labelP2pMode' | translate }}</mat-label>
<mat-select formControlName="p2pMode">
<mat-option *ngFor="let option of p2pModeOptions" [value]="option.value">
{{ option.name }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="form-field" *ngIf="networkSettingsFormGroup.get('p2pMode')?.value === 'seeder'">
<mat-label>{{ 'labelP2pTime' | translate }} (minutos)</mat-label>
<input matInput formControlName="p2pTime" type="number">
</mat-form-field>
</div>
</fieldset>
<!-- ===== Agrupación: Configuración del menú ===== -->
<fieldset class="fieldset-group">
<legend class="fieldset-legend">{{ 'menuConfiguration' | translate }}</legend>
<div class="fieldset-grid-2">
<mat-form-field class="form-field">
<mat-label>{{ 'labelMenu' | translate }}</mat-label>
<mat-select formControlName="menu" (selectionChange)="onMenuChange($event)">
<mat-option *ngFor="let menu of menus" [value]="menu['@id']">
{{ menu.name }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field class="form-field">
<mat-label>{{ 'labelResolution' | translate }}</mat-label>
<mat-select formControlName="resolution">
<mat-option value="769">{{ 'resolution640x480x8' | translate }}</mat-option>
<mat-option value="771">{{ 'resolution800x600x8' | translate }}</mat-option>
<mat-option value="773">{{ 'resolution1024x768x8' | translate }}</mat-option>
<mat-option value="775">{{ 'resolution1280x1024x8' | translate }}</mat-option>
<mat-option value="784">{{ 'resolution640x480x16' | translate }}</mat-option>
<mat-option value="787">{{ 'resolution800x600x16' | translate }}</mat-option>
<mat-option value="791">{{ 'resolution1024x768x16' | translate }}</mat-option>
<mat-option value="794">{{ 'resolution1280x1024x16' | translate }}</mat-option>
<mat-option value="785">{{ 'resolution640x480x24' | translate }}</mat-option>
<mat-option value="788">{{ 'resolution800x600x24' | translate }}</mat-option>
<mat-option value="792">{{ 'resolution1024x768x24' | translate }}</mat-option>
<mat-option value="795">{{ 'resolution1280x1024x24' | translate }}</mat-option>
</mat-select>
</mat-form-field>
</div>
</fieldset>
</form>
<!-- Paso 4: Información Adicional -->
<span *ngIf="!loading" class="step-title">{{ 'labelAdditionalInfoStep' | translate }}</span>
<form *ngIf="additionalInfoFormGroup && !loading" [formGroup]="additionalInfoFormGroup">
<mat-form-field class="form-field description-form-field">
<mat-label>{{ 'labelComments' | translate }}</mat-label>
<textarea matInput formControlName="comments"></textarea>
</mat-form-field>
</form>
</div>
<!-- Paso 4: Información Adicional -->
@if (!loading) {
<span class="step-title">{{ 'labelAdditionalInfoStep' | translate }}</span>
}
@if (additionalInfoFormGroup && !loading) {
<form [formGroup]="additionalInfoFormGroup">
<mat-form-field class="form-field description-form-field">
<mat-label>{{ 'labelComments' | translate }}</mat-label>
<textarea matInput formControlName="comments"></textarea>
</mat-form-field>
</form>
}
</div>
</mat-dialog-content>
<mat-dialog-actions class="action-container">
<button class="ordinary-button" (click)="onNoClick()">{{ 'buttonCancel' | translate }}</button>
<button class="submit-button" (click)="onSubmit()"
[disabled]="!generalFormGroup.valid || !additionalInfoFormGroup.valid || !networkSettingsFormGroup.valid">{{
isEditMode ? ('buttonEdit' | translate) : ('createButton' | translate) }}</button>
<button class="ordinary-button" (click)="onNoClick()">{{ 'buttonCancel' | translate }}</button>
<button class="submit-button" (click)="onSubmit()"
[disabled]="!generalFormGroup.valid || !additionalInfoFormGroup.valid || !networkSettingsFormGroup.valid">{{
isEditMode ? ('buttonEdit' | translate) : ('createButton' | translate) }}</button>
</mat-dialog-actions>

View File

@ -8,6 +8,7 @@ import { DataService } from "../../../services/data.service";
import { ToastrService } from "ngx-toastr";
import { ConfigService } from '@services/config.service';
import { DeleteModalComponent } from '../../../../../shared/delete_modal/delete-modal/delete-modal.component';
import { AuthService } from '@services/auth.service';
@Component({
selector: 'app-manage-organizational-unit',
@ -16,6 +17,7 @@ import { DeleteModalComponent } from '../../../../../shared/delete_modal/delete-
})
export class ManageOrganizationalUnitComponent implements OnInit {
public translate = inject(TranslateService);
public auth = inject(AuthService);
baseUrl: string;
isLinear = true;
generalFormGroup: FormGroup;
@ -108,6 +110,7 @@ export class ManageOrganizationalUnitComponent implements OnInit {
projector: [false],
board: [false],
remotePc: [false],
remotePcRebootOnUnreserve: [false],
capacity: [null, [Validators.required, Validators.min(0)]],
remoteCalendar: [null]
});
@ -141,7 +144,15 @@ export class ManageOrganizationalUnitComponent implements OnInit {
}
get filteredTypes(): string[] {
return this.generalFormGroup.get('parent')?.value ? this.types.filter(type => type !== 'organizational-unit') : this.types;
const isOperator = this.auth.userCategory === 'ou-operator';
const hasParent = !!this.generalFormGroup.get('parent')?.value;
// Si es operador de aula O ya tiene una unidad padre asignada, excluimos 'organizational-unit' (Centro)
if (isOperator || hasParent) {
return this.types.filter(type => type !== 'organizational-unit');
}
return this.types;
}
loadParentUnits(): Promise<void> {
@ -275,6 +286,7 @@ export class ManageOrganizationalUnitComponent implements OnInit {
projector: data.projector,
board: data.board,
remotePc: data.remotePc ?? false,
remotePcRebootOnUnreserve: data.remotePcRebootOnUnreserve ?? false,
capacity: data.capacity,
remoteCalendar: data.remoteCalendar ? data.remoteCalendar['@id'] : null
});
@ -329,7 +341,11 @@ export class ManageOrganizationalUnitComponent implements OnInit {
}
private submitForm(propagate: boolean) {
const parentValue = this.generalFormGroup.value.parent;
let parentValue = this.generalFormGroup.value.parent;
if (typeof parentValue === 'number' || (typeof parentValue === 'string' && !parentValue.includes('/'))) {
const selectedUnit = this.parentUnits.find(u => u.id === Number(parentValue));
parentValue = selectedUnit ? (selectedUnit['@id'] || `/organizational-units/${selectedUnit.uuid}`) : parentValue;
}
const networkSettings = { ...this.networkSettingsFormGroup.value };
@ -350,6 +366,7 @@ export class ManageOrganizationalUnitComponent implements OnInit {
projector: this.classroomInfoFormGroup.value.projector,
board: this.classroomInfoFormGroup.value.board,
remotePc: this.classroomInfoFormGroup.value.remotePc,
remotePcRebootOnUnreserve: this.classroomInfoFormGroup.value.remotePcRebootOnUnreserve,
capacity: this.classroomInfoFormGroup.value.capacity,
};
@ -417,10 +434,7 @@ export class ManageOrganizationalUnitComponent implements OnInit {
);
}
getSelectedParentName(): string | undefined {
const parentId = this.generalFormGroup.get('parent')?.value;
return this.parentUnitsWithPaths.find(unit => unit.id === parentId)?.name;
}
loadBootServers(): Promise<void> {
return new Promise((resolve, reject) => {

View File

@ -62,11 +62,22 @@
.client-info {
display: flex;
flex-direction: column;
max-width: 220px;
}
.client-name {
font-weight: 500;
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.client-more {
font-weight: 400;
font-style: italic;
color: #666;
cursor: default;
}
.client-details {
@ -74,8 +85,4 @@
flex-direction: column;
font-size: 0.75em;
color: #666;
}
.client-ip, .client-mac {
font-family: monospace;
}

View File

@ -47,11 +47,12 @@
</mat-icon>
</ng-container>
<ng-container *ngIf="column.columnDef === 'client'">
<ng-container *ngIf="column.columnDef === 'clients'">
<div *ngIf="column.cell(image) !== 'N/A'; else noClientData" class="client-info">
<div class="client-name">{{ column.cell(image).name }}</div>
<div class="client-details">
<span class="client-ip">{{ column.cell(image).ip }}</span>
<div class="client-name" *ngFor="let assignedClient of getVisibleClients(column.cell(image))">{{ assignedClient.name }}</div>
<div class="client-name client-more" *ngIf="column.cell(image).length > maxVisibleClients"
[matTooltip]="getHiddenClientNames(column.cell(image))">
{{ 'andMoreClients' | translate: { count: column.cell(image).length - maxVisibleClients } }}
</div>
</div>
<ng-template #noClientData>
@ -60,7 +61,7 @@
</ng-container>
<ng-container
*ngIf="column.columnDef !== 'isDefault' && column.columnDef !== 'installed' && column.columnDef !== 'downloadUrl' && column.columnDef !== 'client'">
*ngIf="column.columnDef !== 'isDefault' && column.columnDef !== 'installed' && column.columnDef !== 'downloadUrl' && column.columnDef !== 'clients'">
{{ column.cell(image) }}
</ng-container>
</td>

View File

@ -37,6 +37,7 @@ export class HardwareProfileComponent {
filteredClients!: Observable<any[]>;
clientControl = new FormControl();
clients: any[] = [];
maxVisibleClients: number = 3;
columns = [
{
columnDef: 'id',
@ -49,13 +50,9 @@ export class HardwareProfileComponent {
cell: (hardware: any) => hardware.description
},
{
columnDef: 'client',
header: 'client',
cell: (hardware: any) => hardware.client ? {
name: hardware.client.name || 'N/A',
ip: hardware.client.ip || 'N/A',
mac: hardware.client.mac || 'N/A'
} : 'N/A'
columnDef: 'clients',
header: 'clients',
cell: (hardware: any) => hardware.clients?.length ? hardware.clients : 'N/A'
},
{
columnDef: 'createdAt',
@ -104,16 +101,24 @@ export class HardwareProfileComponent {
}
onOptionClientSelected(selectedClient: any): void {
this.filters['client.id'] = selectedClient.id;
this.filters['clients.id'] = selectedClient.id;
this.search();
}
clearClientFilter(event: Event, clientSearchClientInput: any): void {
clientSearchClientInput.value = '';
this.filters['client.id'] = '';
this.filters['clients.id'] = '';
this.search();
}
getVisibleClients(clients: any): any[] {
return (clients || []).slice(0, this.maxVisibleClients);
}
getHiddenClientNames(clients: any): string {
return (clients || []).slice(this.maxVisibleClients).map((client: any) => client.name).join(', ');
}
private _filterClients(value: string): any[] {
const filterValue = value.toLowerCase();

View File

@ -32,15 +32,6 @@
<input matInput formControlName="comments" name="comments">
</mat-form-field>
<mat-form-field appearance="fill" class="form-field">
<mat-label>{{ 'softwareProfile' | translate }}</mat-label>
<mat-select formControlName="softwareProfile">
<mat-option *ngFor="let profile of softwareProfiles" [value]="profile['@id']">
{{ profile.description }}
</mat-option>
</mat-select>
</mat-form-field>
<mat-checkbox formControlName="remotePc">
{{ 'labelRemotePc' | translate }}
</mat-checkbox>

View File

@ -18,7 +18,6 @@ export class CreateImageComponent implements OnInit {
baseUrl: string;
imageForm: FormGroup<any>;
imageId: string | null = null;
softwareProfiles: any[] = [];
repositories: any[] = [];
loading: boolean = false;
isEditMode: boolean = false;
@ -41,7 +40,6 @@ export class CreateImageComponent implements OnInit {
comments: [''],
remotePc: [false],
isGlobal: [false],
softwareProfile: [''],
imageRepositories: [[], Validators.required],
});
}
@ -54,7 +52,6 @@ export class CreateImageComponent implements OnInit {
} else {
this.loading = false;
}
this.fetchSoftwareProfiles();
this.fetchRepositories();
}
@ -67,7 +64,6 @@ export class CreateImageComponent implements OnInit {
comments: response.comments,
remotePc: response.remotePc,
isGlobal: response.isGlobal,
softwareProfile: response.softwareProfile ? response.softwareProfile['@id'] : null,
imageRepositories: response.imageRepositories ? response.imageRepositories.map((r: any) => r.imageRepository['@id']) : [],
});
this.imageId = response['@id'];
@ -85,18 +81,6 @@ export class CreateImageComponent implements OnInit {
this.showWarning = this.imageForm.get('isGlobal')?.value;
}
fetchSoftwareProfiles() {
const url = `${this.baseUrl}/software-profiles`;
this.http.get(url).subscribe({
next: (response: any) => {
this.softwareProfiles = response['hydra:member'];
},
error: (error) => {
this.toastService.error(this.translate.instant('errorGettingProfiles'));
}
});
}
fetchRepositories() {
const url = `${this.baseUrl}/image-repositories`;
this.http.get(url).subscribe({
@ -117,7 +101,6 @@ export class CreateImageComponent implements OnInit {
remotePc: this.imageForm.value.remotePc,
isGlobal: this.imageForm.value.isGlobal,
imageRepositories: this.imageForm.value.imageRepositories,
...(this.imageForm.value.softwareProfile ? { softwareProfile: this.imageForm.value.softwareProfile } : {}),
};
if (this.imageId) {

View File

@ -64,10 +64,14 @@ export class LoginComponent {
this.auth.refresh();
this.openSnackBar(false, 'Bienvenido ' + this.auth.username);
this.router.navigateByUrl('/groups');
this.dialog.open(GlobalStatusComponent, {
width: '65vw',
height: '80vh',
});
// El estado global consulta y sincroniza infraestructura (ogBoot, DHCP,
// repositorios), a la que solo tienen acceso los perfiles de administración.
if (this.auth.isSuperAdmin || this.auth.isOUAdmin) {
this.dialog.open(GlobalStatusComponent, {
width: '65vw',
height: '80vh',
});
}
}
this.isLoading = false;
},

View File

@ -48,8 +48,8 @@ export class CreateMenuComponent implements OnInit {
}
updateSafeUrl(): void {
const url = `${this.baseUrl}/menu/${this.menuForm.get('publicUrl')?.value}`;
if (url) {
const url = `${this.baseUrl}/menu/${this.menuForm.get('publicUrl')?.value}?gui=1&baseUrl=${encodeURIComponent(this.baseUrl)}`;
if (url && this.menuForm.get('publicUrl')?.value) {
this.safeUrl = this.sanitizer.bypassSecurityTrustResourceUrl(url);
} else {
this.safeUrl = null;
@ -119,23 +119,9 @@ export class CreateMenuComponent implements OnInit {
this.fileContent = base64Content;
// Ask the backend to compile a preview using this encoded Twig
this.dataService.previewMenuTemplate(base64Content).subscribe({
this.dataService.previewMenuTemplate(base64Content, this.baseUrl).subscribe({
next: (renderedHtml) => {
// Ensure relative paths work in the data URL iframe by injecting a <base> tag
const origin = new URL(this.baseUrl).origin;
const baseTag = `<base href="${origin}/">`;
let finalHtml = renderedHtml;
if (finalHtml.toLowerCase().includes('<head>')) {
finalHtml = finalHtml.replace(/<head>/i, `<head>${baseTag}`);
} else if (finalHtml.toLowerCase().includes('<html>')) {
finalHtml = finalHtml.replace(/<html>/i, `<html><head>${baseTag}</head>`);
} else {
finalHtml = `<head>${baseTag}</head>` + finalHtml;
}
// Now encode the definitively rendered HTML to Base64 to populate the iframe preview
const renderedBytes = new TextEncoder().encode(finalHtml);
const renderedBytes = new TextEncoder().encode(renderedHtml);
const renderedBinaryStr = Array.from(renderedBytes).map(byte => String.fromCharCode(byte)).join('');
const renderedBase64 = window.btoa(renderedBinaryStr);

View File

@ -29,9 +29,9 @@ export class DataService {
);
}
previewMenuTemplate(base64Template: string): Observable<string> {
previewMenuTemplate(base64Template: string, baseUrl?: string): Observable<string> {
const url = `${this.baseUrl}/menu-preview`;
return this.http.post(url, { templateBase64: base64Template }, { responseType: 'text' }).pipe(
return this.http.post(url, { templateBase64: base64Template, baseUrl: baseUrl }, { responseType: 'text' }).pipe(
catchError(error => {
console.error(this.translate.instant('errorPreviewingMenuTemplate'), error);
return throwError(() => error);

View File

@ -9,7 +9,7 @@
{{ 'ogbootServers' | translate }}</h2>
</div>
<div class="images-button-row">
<button
<button *ngIf="auth.isSuperAdmin"
class="action-button" (click)="addServer()"
joyrideStep="addStep"
text="Añadir Servidor Boot">{{ 'addBootServer' | translate }}</button>
@ -51,10 +51,10 @@
<button mat-icon-button color="primary" (click)="syncServer(server)" [matTooltip]="'syncServer' | translate">
<mat-icon>sync</mat-icon>
</button>
<button mat-icon-button color="primary" (click)="editServer($event, server)" [matTooltip]="'editServerTitle' | translate">
<button *ngIf="auth.isSuperAdmin" mat-icon-button color="primary" (click)="editServer($event, server)" [matTooltip]="'editServerTitle' | translate">
<mat-icon>edit</mat-icon>
</button>
<button mat-icon-button color="warn" (click)="deleteServer($event, server)" [matTooltip]="'buttonDeleteServer' | translate">
<button *ngIf="auth.isSuperAdmin" mat-icon-button color="warn" (click)="deleteServer($event, server)" [matTooltip]="'buttonDeleteServer' | translate">
<mat-icon>delete</mat-icon>
</button>
</td>

View File

@ -10,6 +10,7 @@ import {DeleteModalComponent} from "../../../shared/delete_modal/delete-modal/de
import { JoyrideService } from 'ngx-joyride';
import { Router } from '@angular/router';
import { ConfigService } from '@services/config.service';
import { AuthService } from '@services/auth.service';
import {ManageBootServerComponent} from "../manage-boot-server/manage-boot-server.component";
import { forkJoin } from 'rxjs';
@ -20,6 +21,7 @@ import { forkJoin } from 'rxjs';
})
export class BootServersComponent implements OnInit {
public translate = inject(TranslateService);
public auth = inject(AuthService);
baseUrl: string;
private apiUrl: string;
dataSource = new MatTableDataSource<any>();

View File

@ -25,15 +25,14 @@
</mat-form-field>
}
<mat-form-field appearance="fill" class="search-boolean" joyrideStep="selectClassStep"
text="{{ 'selectClassDescription' | translate }}">
<mat-label>{{ 'labelSelectClass' | translate }}</mat-label>
<mat-select (selectionChange)="loadChildUnits($event)">
@for (unit of units; track unit.id) {
<mat-option [value]="unit">{{ unit.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<app-ou-select
class="search-boolean"
joyrideStep="selectClassStep"
text="{{ 'selectClassDescription' | translate }}"
[units]="units"
[label]="'labelSelectClass'"
(selectionChange)="loadChildUnits($event)">
</app-ou-select>
</div>

View File

@ -11,6 +11,7 @@ import { ActivatedRoute } from '@angular/router';
import { MatDialog } from '@angular/material/dialog';
import { DeleteModalComponent } from 'src/app/shared/delete_modal/delete-modal/delete-modal.component';
import { forkJoin } from 'rxjs';
import { DataService as GroupsDataService } from '../../groups/services/data.service';
@Component({
selector: 'app-pxe-boot-files',
@ -32,6 +33,7 @@ export class PxeBootFilesComponent implements OnInit {
isFromDashboard: boolean = false;
ogLiveOptions: any[] = [];
globalOgLive: string | null = null;
unitSearchText: string = '';
length: number = 0;
itemsPerPage: number = 10;
page: number = 0;
@ -47,7 +49,8 @@ export class PxeBootFilesComponent implements OnInit {
private configService: ConfigService,
private joyrideService: JoyrideService,
private route: ActivatedRoute,
private dialog: MatDialog
private dialog: MatDialog,
private groupsDataService: GroupsDataService
) {
this.baseUrl = this.configService.apiUrl;
this.taskForm = this.fb.group({
@ -95,14 +98,27 @@ export class PxeBootFilesComponent implements OnInit {
}
loadUnits() {
this.http.get<any>(`${this.baseUrl}/organizational-units?type=classroom&page=1&itemsPerPage=50`).subscribe(
this.http.get<any>(`${this.baseUrl}/organizational-units?page=1&itemsPerPage=500`).subscribe(
response => {
this.units = response['hydra:member'];
const rawUnits = response['hydra:member'];
this.units = rawUnits
.filter((unit: any) => unit.type === 'classroom')
.map((unit: any) => ({
...unit,
path: this.groupsDataService.getOrganizationalUnitPath(unit, rawUnits)
}));
this.units.sort((a, b) => {
const pathA = a.path || a.name;
const pathB = b.path || b.name;
return pathA.localeCompare(pathB);
});
},
error => console.error(this.translate.instant('errorFetchingOrganizationalUnits1'), error)
);
}
fetchPxeTemplates(): void {
this.http.get<any>(`${this.baseUrl}/pxe-templates?page=${this.page + 1}&itemsPerPage=${this.itemsPerPage}`, { params: this.filters })
.subscribe(
@ -117,7 +133,20 @@ export class PxeBootFilesComponent implements OnInit {
}
loadChildUnits(event: any) {
this.selectedUnit = event.value;
let unitId = event.value;
// Buscar la unidad organizativa seleccionada para obtener su ID numérico
const selectedUnit = this.units.find(u => (u['@id'] || u.id) === event.value);
if (selectedUnit && selectedUnit.id) {
unitId = selectedUnit.id;
} else if (typeof event.value === 'object' && event.value !== null) {
unitId = event.value.id;
} else if (typeof event.value === 'string' && event.value.includes('/')) {
// Fallback por si acaso no se encuentra
unitId = event.value.split('/').pop();
}
this.selectedUnit = this.units.find(u => u.id == unitId || u['@id'] === event.value) || event.value;
this.refreshClientList();
}
@ -125,7 +154,10 @@ export class PxeBootFilesComponent implements OnInit {
if (!this.selectedUnit) return;
this.http.get<any>(`${this.baseUrl}/clients?organizationalUnit.id=${this.selectedUnit.id}`).subscribe(
response => {
this.dataSource.data = response['hydra:member'];
this.dataSource.data = response['hydra:member'].map((client: any) => ({
...client,
ogLive: client.pxeTemplate ? client.pxeTemplate['@id'] : null
}));
},
error => console.error(this.translate.instant('errorFetchingChildUnits'), error)
);

View File

@ -39,4 +39,3 @@ mat-dialog-actions {
display: block;
margin-bottom: 10px;
}

View File

@ -3,12 +3,13 @@
<h2 mat-dialog-title>Añade clientes a {{data.subnetName}}</h2>
<mat-dialog-content>
<mat-form-field appearance="fill" class="full-width">
<mat-label>{{ 'selectClassroomPrompt' | translate }}</mat-label>
<mat-select [formControl]="unitControl" (selectionChange)="loadChildUnits($event)">
<mat-option *ngFor="let unit of units" [value]="unit">{{ unit.name }}</mat-option>
</mat-select>
</mat-form-field>
<app-ou-select
class="full-width"
[formControl]="unitControl"
[units]="units"
[label]="'selectClassroomPrompt'"
(selectionChange)="loadChildUnits($event)">
</app-ou-select>
<div class="checkbox-group">
<label>{{ 'labelClients' | translate }}</label>
@ -17,16 +18,20 @@
{{ allSelected ? 'Deseleccionar todos' : 'Seleccionar todos' }}
</button>
<div *ngIf="clients.length > 0" class="clients-grid">
<mat-checkbox *ngFor="let client of clients"
(change)="toggleClientSelection(client.uuid)"
[checked]="selectedClients.includes(client.uuid)">
{{ client.name }}
</mat-checkbox>
</div>
<div *ngIf="clients.length === 0">
<p>{{ 'noClientsMessage' | translate }}</p>
</div>
@if (clients.length > 0) {
<div class="clients-grid">
@for (client of clients; track client.uuid) {
<mat-checkbox (change)="toggleClientSelection(client.uuid)"
[checked]="selectedClients.includes(client.uuid)">
{{ client.name }}
</mat-checkbox>
}
</div>
} @else {
<div>
<p>{{ 'noClientsMessage' | translate }}</p>
</div>
}
</div>
</mat-dialog-content>

View File

@ -7,6 +7,7 @@ import { FormControl } from '@angular/forms';
import { ToastrService } from 'ngx-toastr';
import { OperationResultDialogComponent } from "../operation-result-dialog/operation-result-dialog.component";
import { ConfigService } from '@services/config.service';
import { DataService as GroupsDataService } from "../../groups/services/data.service";
@Component({
selector: 'app-add-clients-to-subnet',
@ -24,12 +25,14 @@ export class AddClientsToSubnetComponent implements OnInit {
childUnitControl = new FormControl();
allSelected: boolean = false;
constructor(
private http: HttpClient,
public dialogRef: MatDialogRef<AddClientsToSubnetComponent>,
private toastService: ToastrService,
public dialog: MatDialog,
private configService: ConfigService,
private groupsDataService: GroupsDataService,
@Inject(MAT_DIALOG_DATA) public data: { subnetUuid: string, subnetName: string }
) {
this.baseUrl = this.configService.apiUrl;
@ -43,15 +46,39 @@ export class AddClientsToSubnetComponent implements OnInit {
loadUnits() {
this.http.get<any>(`${this.baseUrl}/organizational-units?page=1&itemsPerPage=500`).subscribe(
response => {
this.units = response['hydra:member'];
const rawUnits = response['hydra:member'];
this.units = rawUnits.map((unit: any) => ({
...unit,
name: unit.name,
path: this.groupsDataService.getOrganizationalUnitPath(unit, rawUnits)
}));
this.units.sort((a, b) => {
const pathA = a.path || a.name;
const pathB = b.path || b.name;
return pathA.localeCompare(pathB);
});
this.loading = false;
},
error => console.error(this.translate.instant('errorFetchingOrganizationalUnits1'), error)
);
}
loadChildUnits(event: any) {
this.http.get<any>(`${this.baseUrl}/clients?organizationalUnit.id=${event.value.id}&exists[subnet]=false`).subscribe(
let unitId = event.value;
// Buscar la unidad organizativa seleccionada para obtener su ID numérico
const selectedUnit = this.units.find(u => (u['@id'] || u.id) === event.value);
if (selectedUnit && selectedUnit.id) {
unitId = selectedUnit.id;
} else if (typeof event.value === 'object' && event.value !== null) {
unitId = event.value.id;
} else if (typeof event.value === 'string' && event.value.includes('/')) {
// Fallback por si acaso no se encuentra (aunque debería)
unitId = event.value.split('/').pop();
}
this.http.get<any>(`${this.baseUrl}/clients?organizationalUnit.id=${unitId}&exists[subnet]=false`).subscribe(
response => {
this.clients = response['hydra:member'];
},

View File

@ -9,7 +9,7 @@
{{ 'dhcpServers' | translate }}</h2>
</div>
<div class="images-button-row">
<button
<button *ngIf="auth.isSuperAdmin"
class="action-button" (click)="addServer()"
joyrideStep="addStep"
text="Añadir Servidor DHCP">{{ 'addDhcpServer' | translate }}</button>
@ -54,9 +54,9 @@
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef style="text-align: center;">{{ 'actions' | translate }}</th>
<td mat-cell *matCellDef="let server" style="text-align: center;" joyrideStep="actionsStep">
<button mat-icon-button color="primary" (click)="editServer($event, server)" [matTooltip]="'editServerTitle' | translate">
<button *ngIf="auth.isSuperAdmin" mat-icon-button color="primary" (click)="editServer($event, server)" [matTooltip]="'editServerTitle' | translate">
<mat-icon>edit</mat-icon></button>
<button mat-icon-button color="warn" (click)="deleteServer($event, server)" [matTooltip]="'buttonDeleteServer' | translate">
<button *ngIf="auth.isSuperAdmin" mat-icon-button color="warn" (click)="deleteServer($event, server)" [matTooltip]="'buttonDeleteServer' | translate">
<mat-icon>delete</mat-icon>
</button>
</td>

View File

@ -10,6 +10,7 @@ import {DeleteModalComponent} from "../../../shared/delete_modal/delete-modal/de
import { JoyrideService } from 'ngx-joyride';
import { Router } from '@angular/router';
import { ConfigService } from '@services/config.service';
import { AuthService } from '@services/auth.service';
import {ManageDhcpServerComponent} from "../manage-dhcp-server/manage-dhcp-server.component";
@Component({
@ -19,6 +20,7 @@ import {ManageDhcpServerComponent} from "../manage-dhcp-server/manage-dhcp-serve
})
export class DhcpServersComponent implements OnInit {
public translate = inject(TranslateService);
public auth = inject(AuthService);
baseUrl: string;
private apiUrl: string;
dataSource = new MatTableDataSource<any>();

Some files were not shown because too many files have changed in this diff Show More