From 4f8e76866b6ea5c2b1e87f8b50ee5e3d487c7a52 Mon Sep 17 00:00:00 2001 From: stardom3645 Date: Thu, 12 Jun 2025 14:33:19 +0900 Subject: [PATCH 01/17] =?UTF-8?q?rack=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../META-INF/db/schema-Diplo-After.sql | 17 +++++- .../LibvirtRestoreBackupCommandWrapper.java | 12 +--- ui/public/locales/en.json | 3 +- ui/public/locales/ko_KR.json | 3 +- ui/src/config/section/infra/zones.js | 3 + ui/src/views/plugins/IFrameRack.vue | 60 +++++++++++++++++++ ui/vue.config.js | 2 +- 7 files changed, 85 insertions(+), 15 deletions(-) create mode 100644 ui/src/views/plugins/IFrameRack.vue diff --git a/engine/schema/src/main/resources/META-INF/db/schema-Diplo-After.sql b/engine/schema/src/main/resources/META-INF/db/schema-Diplo-After.sql index 3b20c46d3389..276322356a87 100644 --- a/engine/schema/src/main/resources/META-INF/db/schema-Diplo-After.sql +++ b/engine/schema/src/main/resources/META-INF/db/schema-Diplo-After.sql @@ -47,4 +47,19 @@ INSERT IGNORE INTO `cloud`.`vbmc_port` (id, port) VALUES (17, 6246); INSERT IGNORE INTO `cloud`.`vbmc_port` (id, port) VALUES (18, 6247); INSERT IGNORE INTO `cloud`.`vbmc_port` (id, port) VALUES (19, 6248); INSERT IGNORE INTO `cloud`.`vbmc_port` (id, port) VALUES (20, 6249); -INSERT IGNORE INTO `cloud`.`vbmc_port` (id, port) VALUES (21, 6250); \ No newline at end of file +INSERT IGNORE INTO `cloud`.`vbmc_port` (id, port) VALUES (21, 6250); + + +-- BEGIN TABLE rackml_config +CREATE TABLE IF NOT EXISTS `rackml_config` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT 'primary key', + `zone_id` bigint unsigned NOT NULL COMMENT 'foreign key to data_center.id', + `name` varchar(100) NOT NULL COMMENT 'config name (e.g. default)', + `content` text NOT NULL COMMENT 'RackML content', + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'last update time', +PRIMARY KEY (`id`), +UNIQUE KEY `uc_rackml_zone_name` (`zone_id`, `name`), +CONSTRAINT `fk_rackml__zone` FOREIGN KEY (`zone_id`) REFERENCES `data_center` (`id`) + ON DELETE RESTRICT + ON UPDATE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb3; \ No newline at end of file diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreBackupCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreBackupCommandWrapper.java index 0779a2aa468d..aff11cc61a58 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreBackupCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreBackupCommandWrapper.java @@ -78,7 +78,7 @@ public Answer execute(RestoreBackupCommand command, LibvirtComputingResource ser } private void restoreVolumesOfExistingVM(List volumePaths, String backupPath, - String backupRepoType, String backupRepoAddress, String mountOptions) { + String backupRepoType, String backupRepoAddress, String mountOptions) { String diskType = "root"; String mountDirectory = mountBackupDirectory(backupRepoAddress, backupRepoType); try { @@ -151,16 +151,6 @@ private String mountBackupDirectory(String backupRepoAddress, String backupRepoT try { mountDirectory = Files.createTempDirectory(mountDirectory).toString(); String mount = String.format(MOUNT_COMMAND, backupRepoType, backupRepoAddress, mountDirectory); - if ("cifs".equals(backupRepoType)) { - if (Objects.isNull(mountOptions) || mountOptions.trim().isEmpty()) { - mountOptions = "nobrl"; - } else { - mountOptions += ",nobrl"; - } - } - if (Objects.nonNull(mountOptions) && !mountOptions.trim().isEmpty()) { - mount += " -o " + mountOptions; - } Script.runSimpleBashScript(mount); } catch (Exception e) { throw new CloudRuntimeException(String.format("Failed to mount %s to %s", backupRepoType, backupRepoAddress), e); diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index 757e2cb4558b..5896947043ef 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -4769,5 +4769,6 @@ "label.theme.cyan": "Cyan", "label.theme.polar.green": "Polar Green", "label.theme.geek.blue": "Geek Blue", -"label.theme.golden.purple": "Golden Purple" +"label.theme.golden.purple": "Golden Purple", +"label.zone.rack": "Rack" } diff --git a/ui/public/locales/ko_KR.json b/ui/public/locales/ko_KR.json index 74a678643f58..d48c6a2240e6 100644 --- a/ui/public/locales/ko_KR.json +++ b/ui/public/locales/ko_KR.json @@ -4769,5 +4769,6 @@ "label.theme.cyan": "\uc2dc\uc548", "label.theme.polar.green": "\ud3f4\ub77c \uadf8\ub9b0", "label.theme.geek.blue": "\uae31 \ube14\ub8e8", -"label.theme.golden.purple": "\uace8\ub4e0 \ud37c\ud50c" +"label.theme.golden.purple": "\uace8\ub4e0 \ud37c\ud50c", +"label.zone.rack": "\uB799\u0020\uC2DC\uAC01\uD654" } diff --git a/ui/src/config/section/infra/zones.js b/ui/src/config/section/infra/zones.js index b4a34372e101..b3ccd17033e2 100644 --- a/ui/src/config/section/infra/zones.js +++ b/ui/src/config/section/infra/zones.js @@ -72,6 +72,9 @@ export default { }, { name: 'physical.network', component: shallowRef(defineAsyncComponent(() => import('@/views/infra/zone/PhysicalNetworksTab.vue'))) + }, { + name: 'zone.rack', + component: shallowRef(defineAsyncComponent(() => import('@/views/plugins/IFrameRack.vue'))) }, { name: 'ipv4.subnets', component: shallowRef(defineAsyncComponent(() => import('@/views/infra/zone/Ipv4GuestSubnetsTab.vue'))), diff --git a/ui/src/views/plugins/IFrameRack.vue b/ui/src/views/plugins/IFrameRack.vue new file mode 100644 index 000000000000..fa8f3af0325d --- /dev/null +++ b/ui/src/views/plugins/IFrameRack.vue @@ -0,0 +1,60 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + + + + diff --git a/ui/vue.config.js b/ui/vue.config.js index 9cae2ff66fb1..e63f7095f0c2 100644 --- a/ui/vue.config.js +++ b/ui/vue.config.js @@ -138,7 +138,7 @@ const vueConfig = { port: 5050, proxy: { '/client': { - target: process.env.CS_URL || 'http://localhost:8080', + target: process.env.CS_URL || 'http://10.10.253.29:8080', secure: false, ws: false, changeOrigin: true, From 8ce65ccf673db2eb53986321ce61015c5e305669 Mon Sep 17 00:00:00 2001 From: stardom3645 Date: Mon, 14 Jul 2025 16:30:57 +0900 Subject: [PATCH 02/17] =?UTF-8?q?Rack=20=EC=8B=9C=EA=B0=81=ED=99=94=20loca?= =?UTF-8?q?lhost=20->=20listcapabilitiesresponse.capability.host=20?= =?UTF-8?q?=EA=B0=92=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/src/views/plugins/IFrameRack.vue | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/ui/src/views/plugins/IFrameRack.vue b/ui/src/views/plugins/IFrameRack.vue index fa8f3af0325d..364017e74953 100644 --- a/ui/src/views/plugins/IFrameRack.vue +++ b/ui/src/views/plugins/IFrameRack.vue @@ -42,13 +42,12 @@ export default { }, methods: { urlAction () { - const theme = this.$localStorage.get('DARK_MODE') ? '&theme=dark' : '&theme=light' const port = 7077 const zoneId = this.resource.id api('listCapabilities').then(json => { - const host = json.listcapabilitiesresponse.capability.host || 'localhost' - this.uriInfo = `http://${host}:${port}/index.html?zone_id=${zoneId}&name=default${theme}` + const host = json.listcapabilitiesresponse.capability.host || [] + this.uriInfo = `http://${host}:${port}/index.html?zone_id=${zoneId}` this.uriCreateOk = true }).catch(err => { console.error('capabilities API 실패:', err) From 9146ff3c3f56e9a83024f3df728efc61c1b54896 Mon Sep 17 00:00:00 2001 From: stardom3645 Date: Thu, 19 Mar 2026 10:07:07 +0900 Subject: [PATCH 03/17] =?UTF-8?q?[=EB=9E=99=20=EC=8B=9C=EA=B0=81=ED=99=94]?= =?UTF-8?q?=201.=20Rack=20=EC=8B=9C=EA=B0=81=ED=99=94=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/pom.xml | 5 + plugins/integrations/rack-management/pom.xml | 140 ++ .../java/com/cloud/rack/RackLayoutDao.java | 7 + .../com/cloud/rack/RackLayoutDaoImpl.java | 28 + .../com/cloud/rack/RackLayoutManagerImpl.java | 92 + .../com/cloud/rack/RackLayoutService.java | 13 + .../java/com/cloud/rack/RackLayoutVO.java | 55 + .../admin/rack/ListRackLayoutsCmd.java | 69 + .../admin/rack/UpdateRackLayoutCmd.java | 84 + .../api/response/RackLayoutResponse.java | 59 + .../rack-management/module.properties | 18 + .../spring-rack-management-context.xml | 33 + plugins/pom.xml | 1 + tools/marvin/setup.py | 2 +- ui/package-lock.json | 38 + ui/package.json | 3 +- ui/public/locales/en.json | 2 +- ui/public/locales/ko_KR.json | 2 +- ui/src/components/page/GlobalLayout.vue | 27 +- ui/src/config/section/infra/zones.js | 2 +- ui/src/views/infra/zone/RackDiagramTab.vue | 1579 +++++++++++++++++ ui/src/views/plugins/IFrameRack.vue | 59 - ui/vue.config.js | 2 +- 23 files changed, 2253 insertions(+), 67 deletions(-) create mode 100644 plugins/integrations/rack-management/pom.xml create mode 100644 plugins/integrations/rack-management/src/main/java/com/cloud/rack/RackLayoutDao.java create mode 100644 plugins/integrations/rack-management/src/main/java/com/cloud/rack/RackLayoutDaoImpl.java create mode 100644 plugins/integrations/rack-management/src/main/java/com/cloud/rack/RackLayoutManagerImpl.java create mode 100644 plugins/integrations/rack-management/src/main/java/com/cloud/rack/RackLayoutService.java create mode 100644 plugins/integrations/rack-management/src/main/java/com/cloud/rack/RackLayoutVO.java create mode 100644 plugins/integrations/rack-management/src/main/java/org/apache/cloudstack/api/command/admin/rack/ListRackLayoutsCmd.java create mode 100644 plugins/integrations/rack-management/src/main/java/org/apache/cloudstack/api/command/admin/rack/UpdateRackLayoutCmd.java create mode 100644 plugins/integrations/rack-management/src/main/java/org/apache/cloudstack/api/response/RackLayoutResponse.java create mode 100644 plugins/integrations/rack-management/src/main/resources/META-INF/cloudstack/rack-management/module.properties create mode 100644 plugins/integrations/rack-management/src/main/resources/META-INF/cloudstack/rack-management/spring-rack-management-context.xml create mode 100644 ui/src/views/infra/zone/RackDiagramTab.vue delete mode 100644 ui/src/views/plugins/IFrameRack.vue diff --git a/client/pom.xml b/client/pom.xml index 03991a2f5dc7..224a145152b0 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -647,6 +647,11 @@ cloud-plugin-integrations-wall-alerts ${project.version} + + org.apache.cloudstack + cloud-plugin-integrations-rack-management + ${project.version} + org.apache.cloudstack cloud-plugin-shutdown diff --git a/plugins/integrations/rack-management/pom.xml b/plugins/integrations/rack-management/pom.xml new file mode 100644 index 000000000000..0da8097b55bf --- /dev/null +++ b/plugins/integrations/rack-management/pom.xml @@ -0,0 +1,140 @@ + + + + 4.0.0 + cloud-plugin-integrations-rack-management + Apache CloudStack Plugin - Rack Management + + org.apache.cloudstack + cloudstack-plugins + 4.21.0.0-SNAPSHOT + ../../pom.xml + + + + org.apache.cloudstack + cloud-core + ${project.version} + + + org.apache.cloudstack + cloud-framework-db + ${project.version} + + + org.apache.cloudstack + cloud-framework-ca + ${project.version} + + + org.apache.cloudstack + cloud-framework-security + ${project.version} + + + org.apache.cloudstack + cloud-engine-schema + ${project.version} + + + org.apache.cloudstack + cloud-engine-api + ${project.version} + + + org.apache.cloudstack + cloud-engine-components-api + ${project.version} + + + org.apache.cloudstack + cloud-framework-managed-context + ${project.version} + + + org.eclipse.persistence + javax.persistence + ${cs.jpa.version} + + + com.google.code.gson + gson + ${cs.gson.version} + + + com.google.guava + guava + ${cs.guava.version} + + + ch.qos.reload4j + reload4j + ${cs.reload4j.version} + + + org.springframework + spring-context + ${org.springframework.version} + + + org.springframework + spring-aop + ${org.springframework.version} + + + org.springframework + spring-beans + ${org.springframework.version} + + + org.springframework + spring-test + ${org.springframework.version} + + + commons-codec + commons-codec + ${cs.codec.version} + + + org.hamcrest + hamcrest-library + ${cs.hamcrest.version} + test + + + org.bouncycastle + bcprov-jdk15on + ${cs.bcprov.version} + + + org.bouncycastle + bctls-jdk15on + ${cs.bcprov.version} + + + joda-time + joda-time + ${cs.joda-time.version} + + + diff --git a/plugins/integrations/rack-management/src/main/java/com/cloud/rack/RackLayoutDao.java b/plugins/integrations/rack-management/src/main/java/com/cloud/rack/RackLayoutDao.java new file mode 100644 index 000000000000..6985feb90346 --- /dev/null +++ b/plugins/integrations/rack-management/src/main/java/com/cloud/rack/RackLayoutDao.java @@ -0,0 +1,7 @@ +package com.cloud.rack; + +import com.cloud.utils.db.GenericDao; + +public interface RackLayoutDao extends GenericDao { + RackLayoutVO findByZoneAndName(Long zoneId, String name); +} \ No newline at end of file diff --git a/plugins/integrations/rack-management/src/main/java/com/cloud/rack/RackLayoutDaoImpl.java b/plugins/integrations/rack-management/src/main/java/com/cloud/rack/RackLayoutDaoImpl.java new file mode 100644 index 000000000000..db46e01ed7aa --- /dev/null +++ b/plugins/integrations/rack-management/src/main/java/com/cloud/rack/RackLayoutDaoImpl.java @@ -0,0 +1,28 @@ +package com.cloud.rack; + +import org.springframework.stereotype.Component; + +import com.cloud.utils.db.GenericDaoBase; +import com.cloud.utils.db.SearchBuilder; +import com.cloud.utils.db.SearchCriteria; + +@Component +public class RackLayoutDaoImpl extends GenericDaoBase implements RackLayoutDao { + + private final SearchBuilder ZoneNameSearch; + + public RackLayoutDaoImpl() { + ZoneNameSearch = createSearchBuilder(); + ZoneNameSearch.and("zoneId", ZoneNameSearch.entity().getZoneId(), SearchCriteria.Op.EQ); + ZoneNameSearch.and("name", ZoneNameSearch.entity().getName(), SearchCriteria.Op.EQ); + ZoneNameSearch.done(); + } + + @Override + public RackLayoutVO findByZoneAndName(Long zoneId, String name) { + SearchCriteria sc = ZoneNameSearch.create(); + sc.setParameters("zoneId", zoneId); + sc.setParameters("name", name); + return findOneBy(sc); + } +} \ No newline at end of file diff --git a/plugins/integrations/rack-management/src/main/java/com/cloud/rack/RackLayoutManagerImpl.java b/plugins/integrations/rack-management/src/main/java/com/cloud/rack/RackLayoutManagerImpl.java new file mode 100644 index 000000000000..acca31279f1f --- /dev/null +++ b/plugins/integrations/rack-management/src/main/java/com/cloud/rack/RackLayoutManagerImpl.java @@ -0,0 +1,92 @@ +package com.cloud.rack; + +import java.util.ArrayList; +import java.util.List; +import javax.inject.Inject; + +import org.apache.log4j.Logger; +import org.springframework.stereotype.Component; + +import org.apache.cloudstack.api.command.admin.rack.ListRackLayoutsCmd; +import org.apache.cloudstack.api.command.admin.rack.UpdateRackLayoutCmd; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.api.response.RackLayoutResponse; +import com.cloud.dc.DataCenterVO; +import com.cloud.dc.dao.DataCenterDao; +import com.cloud.utils.component.ManagerBase; + +@Component +public class RackLayoutManagerImpl extends ManagerBase implements RackLayoutService { + private static final Logger s_logger = Logger.getLogger(RackLayoutManagerImpl.class); + + @Inject + private RackLayoutDao rackLayoutDao; + + @Inject + private DataCenterDao dataCenterDao; + + @Override + public ListResponse listRackLayouts(ListRackLayoutsCmd cmd) { + Long zoneId = cmd.getZoneId(); + String name = cmd.getName(); + + RackLayoutVO vo = rackLayoutDao.findByZoneAndName(zoneId, name); + List responses = new ArrayList<>(); + + if (vo != null) { + responses.add(createRackLayoutResponse(vo)); + } + + ListResponse listResponse = new ListResponse<>(); + listResponse.setResponses(responses); + return listResponse; + } + + @Override + public RackLayoutResponse updateRackLayout(UpdateRackLayoutCmd cmd) { + Long zoneId = cmd.getZoneId(); + String name = cmd.getName(); + String content = cmd.getContent(); + + RackLayoutVO vo = rackLayoutDao.findByZoneAndName(zoneId, name); + + if (vo == null) { + // (INSERT) + vo = new RackLayoutVO(zoneId, name, content); + rackLayoutDao.persist(vo); + s_logger.debug("Created new rack layout for zone: " + zoneId + ", name: " + name); + } else { + // (UPDATE) + vo.setContent(content); + rackLayoutDao.update(vo.getId(), vo); + s_logger.debug("Updated existing rack layout for zone: " + zoneId + ", name: " + name); + } + + return createRackLayoutResponse(vo); + } + + // VO 객체를 API Response 포맷으로 예쁘게 변환해 주는 헬퍼 메서드 + private RackLayoutResponse createRackLayoutResponse(RackLayoutVO vo) { + RackLayoutResponse response = new RackLayoutResponse(); + response.setId(String.valueOf(vo.getId())); + response.setName(vo.getName()); + response.setContent(vo.getContent()); + + // 프론트엔드가 사용할 수 있도록 Zone ID를 UUID 포맷으로 변환하여 반환 + DataCenterVO zone = dataCenterDao.findById(vo.getZoneId()); + if (zone != null) { + response.setZoneId(zone.getUuid()); + } + + response.setObjectName("racklayout"); + return response; + } + + @Override + public List> getCommands() { + List> cmdList = new ArrayList<>(); + cmdList.add(ListRackLayoutsCmd.class); + cmdList.add(UpdateRackLayoutCmd.class); + return cmdList; + } +} \ No newline at end of file diff --git a/plugins/integrations/rack-management/src/main/java/com/cloud/rack/RackLayoutService.java b/plugins/integrations/rack-management/src/main/java/com/cloud/rack/RackLayoutService.java new file mode 100644 index 000000000000..259c4089ffdc --- /dev/null +++ b/plugins/integrations/rack-management/src/main/java/com/cloud/rack/RackLayoutService.java @@ -0,0 +1,13 @@ +package com.cloud.rack; + +import org.apache.cloudstack.api.command.admin.rack.ListRackLayoutsCmd; +import org.apache.cloudstack.api.command.admin.rack.UpdateRackLayoutCmd; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.api.response.RackLayoutResponse; + +import com.cloud.utils.component.PluggableService; + +public interface RackLayoutService extends PluggableService { + ListResponse listRackLayouts(ListRackLayoutsCmd cmd); + RackLayoutResponse updateRackLayout(UpdateRackLayoutCmd cmd); +} \ No newline at end of file diff --git a/plugins/integrations/rack-management/src/main/java/com/cloud/rack/RackLayoutVO.java b/plugins/integrations/rack-management/src/main/java/com/cloud/rack/RackLayoutVO.java new file mode 100644 index 000000000000..9f9cfb288727 --- /dev/null +++ b/plugins/integrations/rack-management/src/main/java/com/cloud/rack/RackLayoutVO.java @@ -0,0 +1,55 @@ +package com.cloud.rack; + +import java.util.Date; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Table; +import javax.persistence.Temporal; +import javax.persistence.TemporalType; + +import org.apache.cloudstack.api.InternalIdentity; + +@Entity +@Table(name = "rackml_config") +public class RackLayoutVO implements InternalIdentity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "zone_id") + private Long zoneId; + + @Column(name = "name") + private String name; + + @Column(name = "content", length = 65535) + private String content; + + @Temporal(TemporalType.TIMESTAMP) + @Column(name = "updated_at", updatable = false) + private Date updatedAt; + + public RackLayoutVO() { + } + + public RackLayoutVO(Long zoneId, String name, String content) { + this.zoneId = zoneId; + this.name = name; + this.content = content; + } + + @Override + public long getId() { return id; } + public Long getZoneId() { return zoneId; } + public void setZoneId(Long zoneId) { this.zoneId = zoneId; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getContent() { return content; } + public void setContent(String content) { this.content = content; } + public Date getUpdatedAt() { return updatedAt; } +} \ No newline at end of file diff --git a/plugins/integrations/rack-management/src/main/java/org/apache/cloudstack/api/command/admin/rack/ListRackLayoutsCmd.java b/plugins/integrations/rack-management/src/main/java/org/apache/cloudstack/api/command/admin/rack/ListRackLayoutsCmd.java new file mode 100644 index 000000000000..bbcde6a03786 --- /dev/null +++ b/plugins/integrations/rack-management/src/main/java/org/apache/cloudstack/api/command/admin/rack/ListRackLayoutsCmd.java @@ -0,0 +1,69 @@ +package org.apache.cloudstack.api.command.admin.rack; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseListCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ResponseObject.ResponseView; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.ListResponse; +import org.apache.cloudstack.api.response.RackLayoutResponse; +import org.apache.cloudstack.api.response.ZoneResponse; + +import com.cloud.rack.RackLayoutService; + +@APICommand(name = ListRackLayoutsCmd.APINAME, + description = "Lists rack layout configurations", + responseObject = RackLayoutResponse.class, + responseView = ResponseView.Restricted, + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin, RoleType.User}) +public class ListRackLayoutsCmd extends BaseListCmd { + public static final String APINAME = "listRackLayouts"; + + @Inject + private RackLayoutService rackLayoutService; + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + + @Parameter(name = ApiConstants.ZONE_ID, type = CommandType.UUID, entityType = ZoneResponse.class, required = true, + description = "the ID of the zone") + private Long zoneId; + + @Parameter(name = ApiConstants.NAME, type = CommandType.STRING, required = false, + description = "name of the layout. If not specified, returns the 'default' layout.") + private String name; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public Long getZoneId() { + return zoneId; + } + + public String getName() { + return name == null ? "default" : name; + } + + @Override + public String getCommandName() { + return APINAME.toLowerCase() + "response"; + } + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + + @Override + public void execute() throws ServerApiException { + // BaseListCmd를 상속받았으므로 ListResponse로 반환 + ListResponse response = rackLayoutService.listRackLayouts(this); + response.setResponseName(getCommandName()); + setResponseObject(response); + } +} \ No newline at end of file diff --git a/plugins/integrations/rack-management/src/main/java/org/apache/cloudstack/api/command/admin/rack/UpdateRackLayoutCmd.java b/plugins/integrations/rack-management/src/main/java/org/apache/cloudstack/api/command/admin/rack/UpdateRackLayoutCmd.java new file mode 100644 index 000000000000..ec0281cc68d8 --- /dev/null +++ b/plugins/integrations/rack-management/src/main/java/org/apache/cloudstack/api/command/admin/rack/UpdateRackLayoutCmd.java @@ -0,0 +1,84 @@ +package org.apache.cloudstack.api.command.admin.rack; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ResponseObject.ResponseView; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.RackLayoutResponse; +import org.apache.cloudstack.api.response.ZoneResponse; +import com.cloud.user.Account; + +// ※ 참고: RackLayoutService는 다음 단계에서 만들 서비스 인터페이스입니다. +import com.cloud.rack.RackLayoutService; + +@APICommand(name = UpdateRackLayoutCmd.APINAME, + description = "Updates or creates a rack layout configuration for a specific zone", + responseObject = RackLayoutResponse.class, + responseView = ResponseView.Restricted, + authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin}) +public class UpdateRackLayoutCmd extends BaseCmd { + public static final String APINAME = "updateRackLayout"; + + @Inject + private RackLayoutService rackLayoutService; + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + + @Parameter(name = ApiConstants.ZONE_ID, type = CommandType.UUID, entityType = ZoneResponse.class, required = true, + description = "the ID of the zone") + private Long zoneId; + + @Parameter(name = ApiConstants.NAME, type = CommandType.STRING, required = false, + description = "name of the layout. Defaults to 'default' if not specified.") + private String name; + + // JSON 길이가 길 수 있으므로 length를 넉넉히 잡아줍니다. + @Parameter(name = "content", type = CommandType.STRING, required = true, length = 65535, + description = "JSON string representing the rack diagram layout") + private String content; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + + public Long getZoneId() { + return zoneId; + } + + public String getName() { + return name == null ? "default" : name; + } + + public String getContent() { + return content; + } + + @Override + public String getCommandName() { + return APINAME.toLowerCase() + "response"; + } + + @Override + public long getEntityOwnerId() { + return Account.ACCOUNT_ID_SYSTEM; + } + + ///////////////////////////////////////////////////// + /////////////// API Implementation/////////////////// + ///////////////////////////////////////////////////// + + @Override + public void execute() throws ServerApiException { + // 서비스 레이어 호출하여 로직 수행 후 결과 받기 + RackLayoutResponse response = rackLayoutService.updateRackLayout(this); + response.setResponseName(getCommandName()); + setResponseObject(response); + } +} \ No newline at end of file diff --git a/plugins/integrations/rack-management/src/main/java/org/apache/cloudstack/api/response/RackLayoutResponse.java b/plugins/integrations/rack-management/src/main/java/org/apache/cloudstack/api/response/RackLayoutResponse.java new file mode 100644 index 000000000000..136f7f06aca0 --- /dev/null +++ b/plugins/integrations/rack-management/src/main/java/org/apache/cloudstack/api/response/RackLayoutResponse.java @@ -0,0 +1,59 @@ +package org.apache.cloudstack.api.response; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; + +@SuppressWarnings("unused") +public class RackLayoutResponse extends BaseResponse { + + @SerializedName(ApiConstants.ID) + @Param(description = "the ID of the rack layout") + private String id; + + @SerializedName(ApiConstants.ZONE_ID) + @Param(description = "the ID of the zone for this rack layout") + private String zoneId; + + @SerializedName(ApiConstants.NAME) + @Param(description = "the name of the rack layout (e.g., default, room1)") + private String name; + + // 프론트엔드에서 파싱할 JSON 문자열을 그대로 담아줍니다. + @SerializedName("content") + @Param(description = "JSON string containing the actual rack layout configuration") + private String content; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getZoneId() { + return zoneId; + } + + public void setZoneId(String zoneId) { + this.zoneId = zoneId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } +} \ No newline at end of file diff --git a/plugins/integrations/rack-management/src/main/resources/META-INF/cloudstack/rack-management/module.properties b/plugins/integrations/rack-management/src/main/resources/META-INF/cloudstack/rack-management/module.properties new file mode 100644 index 000000000000..6cb4a122d6fa --- /dev/null +++ b/plugins/integrations/rack-management/src/main/resources/META-INF/cloudstack/rack-management/module.properties @@ -0,0 +1,18 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +name=rack-management +parent=api diff --git a/plugins/integrations/rack-management/src/main/resources/META-INF/cloudstack/rack-management/spring-rack-management-context.xml b/plugins/integrations/rack-management/src/main/resources/META-INF/cloudstack/rack-management/spring-rack-management-context.xml new file mode 100644 index 000000000000..768a9858d886 --- /dev/null +++ b/plugins/integrations/rack-management/src/main/resources/META-INF/cloudstack/rack-management/spring-rack-management-context.xml @@ -0,0 +1,33 @@ + + + + + + + diff --git a/plugins/pom.xml b/plugins/pom.xml index ccb611b4b4df..e99d7a452b01 100755 --- a/plugins/pom.xml +++ b/plugins/pom.xml @@ -102,6 +102,7 @@ integrations/desktop-service integrations/automation-service integrations/disaster-recovery + integrations/rack-management metrics network-elements/bigswitch diff --git a/tools/marvin/setup.py b/tools/marvin/setup.py index 11a63a96aced..803829736892 100644 --- a/tools/marvin/setup.py +++ b/tools/marvin/setup.py @@ -27,7 +27,7 @@ raise RuntimeError("python setuptools is required to build Marvin") -VERSION = "4.21.0.0-SNAPSHOT" +VERSION = "4.21.0.0" setup(name="Marvin", version=VERSION, diff --git a/ui/package-lock.json b/ui/package-lock.json index 8210a2fe16e1..01936eb9e314 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -6394,6 +6394,11 @@ } } }, + "base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==" + }, "base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -8303,6 +8308,14 @@ "timsort": "^0.3.0" } }, + "css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "requires": { + "utrie": "^1.0.2" + } + }, "css-loader": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-3.6.0.tgz", @@ -12222,6 +12235,15 @@ } } }, + "html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "requires": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + } + }, "htmlparser2": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", @@ -21702,6 +21724,14 @@ "require-main-filename": "^2.0.0" } }, + "text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "requires": { + "utrie": "^1.0.2" + } + }, "text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -22625,6 +22655,14 @@ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", "dev": true }, + "utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "requires": { + "base64-arraybuffer": "^1.0.2" + } + }, "uuid": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", diff --git a/ui/package.json b/ui/package.json index fd6b763e7d9a..7244c8f791df 100644 --- a/ui/package.json +++ b/ui/package.json @@ -52,6 +52,7 @@ "cronstrue": "^2.26.0", "dompurify": "^3.3.1", "enquire.js": "^2.1.6", + "html2canvas": "^1.4.1", "js-cookie": "^2.2.1", "lodash": "^4.17.15", "markdown-it": "^14.1.0", @@ -129,7 +130,7 @@ "vue/max-attributes-per-line": [ 2, { - "singleline": 5, + "singleline": 7, "multiline": { "max": 1, "allowFirstLine": false diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index 1f984202d55f..e0df09e75899 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -4785,7 +4785,7 @@ "label.theme.polar.green": "Polar Green", "label.theme.geek.blue": "Geek Blue", "label.theme.golden.purple": "Golden Purple", -"label.zone.rack": "Rack" +"label.zone.rack": "Rack", "label.usb.devices": "USB Devices", "label.lun.devices": "LUN Devices", "label.other.devices": "Other Devices", diff --git a/ui/public/locales/ko_KR.json b/ui/public/locales/ko_KR.json index 9c31b04f2bd5..cb9a35036a4f 100644 --- a/ui/public/locales/ko_KR.json +++ b/ui/public/locales/ko_KR.json @@ -4775,7 +4775,7 @@ "label.theme.polar.green": "\ud3f4\ub77c \uadf8\ub9b0", "label.theme.geek.blue": "\uae31 \ube14\ub8e8", "label.theme.golden.purple": "\uace8\ub4e0 \ud37c\ud50c", -"label.zone.rack": "\uB799\u0020\uC2DC\uAC01\uD654" +"label.zone.rack": "\uB799\u0020\uC2DC\uAC01\uD654", "label.usb.devices": "USB \ub514\ubc14\uc774\uc2a4", "label.lun.devices": "LUN \ub514\ubc14\uc774\uc2a4", "label.other.devices": "\uae30\ud0c0 \ub514\ubc14\uc774\uc2a4", diff --git a/ui/src/components/page/GlobalLayout.vue b/ui/src/components/page/GlobalLayout.vue index cc9edbbde674..a21fa36d47ac 100644 --- a/ui/src/components/page/GlobalLayout.vue +++ b/ui/src/components/page/GlobalLayout.vue @@ -233,14 +233,18 @@ export default { }, mounted () { try { + /* const bootH = Number(localStorage.getItem('autoAlertBanner.lastHeight') || 0) if (!Number.isNaN(bootH) && bootH >= 0) { this.autoBannerHeight = bootH document.documentElement.style.setProperty('--autoBannerHeight', bootH + 'px') - // 추가: 첫 프레임부터 --affixTopHeader를 맞춰 메뉴/헤더 정렬 - this.updateAffixTopVars() // ← 이 한 줄을 debouncedRecalc() 이전에 호출 + this.updateAffixTopVars() this.debouncedRecalc && this.debouncedRecalc() } + */ + this.autoBannerHeight = 0 + document.documentElement.style.setProperty('--autoBannerHeight', '0px') + this.updateAffixTopVars() } catch (_) {} window.addEventListener('auto-alert-banner:height', this.onAutoBannerHeight) window.addEventListener('resize', this.onResize) @@ -397,6 +401,25 @@ export default { will-change: height; } +banner-spacer::before { + content: ""; + position: fixed; + top: 0; + left: 0; + right: 0; + /* 배너가 들어올 자리(높이)만큼 완벽하게 덮어줍니다 */ + height: var(--affixTopHeader, 0px); + /* 시스템 배경색으로 칠해서 스크롤되는 본문을 가려줍니다 */ + background-color: var(--layout-bg, #f0f2f5); + z-index: 1490; /* 헤더(1500) 바로 밑, 본문보다는 위에 배치 */ + pointer-events: none; /* 클릭 방해 금지 */ +} + +/* 다크모드가 켜졌을 때 가림막 색상 보정 */ +body.dark-mode .banner-spacer::before { + background-color: #141414; +} + /* 고정 헤더 사용 시 컨텐츠 상단 여백 */ .layout-content { &.is-header-fixed { diff --git a/ui/src/config/section/infra/zones.js b/ui/src/config/section/infra/zones.js index 081c04869f06..7e6f08d83286 100644 --- a/ui/src/config/section/infra/zones.js +++ b/ui/src/config/section/infra/zones.js @@ -74,7 +74,7 @@ export default { component: shallowRef(defineAsyncComponent(() => import('@/views/infra/zone/PhysicalNetworksTab.vue'))) }, { name: 'zone.rack', - component: shallowRef(defineAsyncComponent(() => import('@/views/plugins/IFrameRack.vue'))) + component: shallowRef(defineAsyncComponent(() => import('@/views/infra/zone/RackDiagramTab.vue'))) }, { name: 'ipv4.subnets', component: shallowRef(defineAsyncComponent(() => import('@/views/infra/zone/Ipv4GuestSubnetsTab.vue'))), diff --git a/ui/src/views/infra/zone/RackDiagramTab.vue b/ui/src/views/infra/zone/RackDiagramTab.vue new file mode 100644 index 000000000000..ca0877316467 --- /dev/null +++ b/ui/src/views/infra/zone/RackDiagramTab.vue @@ -0,0 +1,1579 @@ + + + + + diff --git a/ui/src/views/plugins/IFrameRack.vue b/ui/src/views/plugins/IFrameRack.vue deleted file mode 100644 index 364017e74953..000000000000 --- a/ui/src/views/plugins/IFrameRack.vue +++ /dev/null @@ -1,59 +0,0 @@ -// Licensed to the Apache Software Foundation (ASF) under one -// or more contributor license agreements. See the NOTICE file -// distributed with this work for additional information -// regarding copyright ownership. The ASF licenses this file -// to you under the Apache License, Version 2.0 (the -// "License"); you may not use this file except in compliance -// with the License. You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, -// software distributed under the License is distributed on an -// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -// KIND, either express or implied. See the License for the -// specific language governing permissions and limitations -// under the License. - - - - diff --git a/ui/vue.config.js b/ui/vue.config.js index e63f7095f0c2..318ef86aefe8 100644 --- a/ui/vue.config.js +++ b/ui/vue.config.js @@ -138,7 +138,7 @@ const vueConfig = { port: 5050, proxy: { '/client': { - target: process.env.CS_URL || 'http://10.10.253.29:8080', + target: process.env.CS_URL || 'http://10.10.218.29:8080', secure: false, ws: false, changeOrigin: true, From 761b17d25b866425eb02d40e7bc012a37755d5c4 Mon Sep 17 00:00:00 2001 From: stardom3645 Date: Thu, 19 Mar 2026 10:15:22 +0900 Subject: [PATCH 04/17] =?UTF-8?q?=EB=A1=A4=EB=B0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wrapper/LibvirtRestoreBackupCommandWrapper.java | 10 ++++++++++ tools/marvin/setup.py | 2 +- ui/vue.config.js | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreBackupCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreBackupCommandWrapper.java index ff32d9c95e82..de867432cc1e 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreBackupCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtRestoreBackupCommandWrapper.java @@ -154,6 +154,16 @@ private String mountBackupDirectory(String backupRepoAddress, String backupRepoT try { mountDirectory = Files.createTempDirectory(mountDirectory).toString(); String mount = String.format(MOUNT_COMMAND, backupRepoType, backupRepoAddress, mountDirectory); + if ("cifs".equals(backupRepoType)) { + if (Objects.isNull(mountOptions) || mountOptions.trim().isEmpty()) { + mountOptions = "nobrl"; + } else { + mountOptions += ",nobrl"; + } + } + if (Objects.nonNull(mountOptions) && !mountOptions.trim().isEmpty()) { + mount += " -o " + mountOptions; + } Script.runSimpleBashScript(mount); } catch (Exception e) { throw new CloudRuntimeException(String.format("Failed to mount %s to %s", backupRepoType, backupRepoAddress), e); diff --git a/tools/marvin/setup.py b/tools/marvin/setup.py index 803829736892..11a63a96aced 100644 --- a/tools/marvin/setup.py +++ b/tools/marvin/setup.py @@ -27,7 +27,7 @@ raise RuntimeError("python setuptools is required to build Marvin") -VERSION = "4.21.0.0" +VERSION = "4.21.0.0-SNAPSHOT" setup(name="Marvin", version=VERSION, diff --git a/ui/vue.config.js b/ui/vue.config.js index 318ef86aefe8..9cae2ff66fb1 100644 --- a/ui/vue.config.js +++ b/ui/vue.config.js @@ -138,7 +138,7 @@ const vueConfig = { port: 5050, proxy: { '/client': { - target: process.env.CS_URL || 'http://10.10.218.29:8080', + target: process.env.CS_URL || 'http://localhost:8080', secure: false, ws: false, changeOrigin: true, From 434fbe13c27b5014bda10351be5e6c0183afcbbf Mon Sep 17 00:00:00 2001 From: stardom3645 Date: Wed, 6 May 2026 10:40:48 +0900 Subject: [PATCH 05/17] =?UTF-8?q?[=EB=9E=99=20=EC=8B=9C=EA=B0=81=ED=99=94]?= =?UTF-8?q?=20=EB=93=9C=EB=9E=98=EA=B7=B8=EB=A1=9C=20=EC=9E=A5=EC=B9=98?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD.=20=EC=9E=A5=EC=B9=98=EB=A7=88=EB=8B=A4=20=EC=BB=A4?= =?UTF-8?q?=EC=8A=A4=ED=85=80=20=EB=A7=81=ED=81=AC=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EA=B0=80=EB=8A=A5.=20=ED=98=B8=EC=8A=A4=ED=8A=B8=EC=9D=98=20?= =?UTF-8?q?=EA=B2=BD=EC=9A=B0=20=EC=9A=B4=EC=98=81=EC=9E=A5=EB=B9=84?= =?UTF-8?q?=EC=99=80=20=EB=A7=A4=EC=B9=AD=ED=95=A0=20=EC=88=98=20=EC=9E=88?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80.?= =?UTF-8?q?=20=ED=98=B8=EC=8A=A4=ED=8A=B8=20=ED=8F=AC=ED=84=B8=20=EB=A7=81?= =?UTF-8?q?=ED=81=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/package-lock.json | 50 ++ ui/src/config/section/infra/zones.js | 3 - ui/src/views/infra/InfraSummary.vue | 116 ++- ui/src/views/infra/zone/RackDiagramTab.vue | 883 +++++++++++++++++---- ui/vue.config.js | 2 +- 5 files changed, 865 insertions(+), 189 deletions(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index 56534debe41f..22854acfafae 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -27,6 +27,7 @@ "cronstrue": "^2.26.0", "dompurify": "^3.3.1", "enquire.js": "^2.1.6", + "html2canvas": "^1.4.1", "js-cookie": "^2.2.1", "lodash": "^4.17.15", "markdown-it": "^14.1.1", @@ -8330,6 +8331,15 @@ "node": ">=0.10.0" } }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -10680,6 +10690,15 @@ "node": ">4" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/css-loader": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-3.6.0.tgz", @@ -15606,6 +15625,19 @@ "object.getownpropertydescriptors": "^2.0.3" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/htmlparser2": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", @@ -27695,6 +27727,15 @@ "node": ">=6" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -28807,6 +28848,15 @@ "node": ">= 0.4.0" } }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/uuid": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", diff --git a/ui/src/config/section/infra/zones.js b/ui/src/config/section/infra/zones.js index 7e6f08d83286..a428b3618dc7 100644 --- a/ui/src/config/section/infra/zones.js +++ b/ui/src/config/section/infra/zones.js @@ -72,9 +72,6 @@ export default { }, { name: 'physical.network', component: shallowRef(defineAsyncComponent(() => import('@/views/infra/zone/PhysicalNetworksTab.vue'))) - }, { - name: 'zone.rack', - component: shallowRef(defineAsyncComponent(() => import('@/views/infra/zone/RackDiagramTab.vue'))) }, { name: 'ipv4.subnets', component: shallowRef(defineAsyncComponent(() => import('@/views/infra/zone/Ipv4GuestSubnetsTab.vue'))), diff --git a/ui/src/views/infra/InfraSummary.vue b/ui/src/views/infra/InfraSummary.vue index a35db46f7608..27a3e8c0e6fa 100644 --- a/ui/src/views/infra/InfraSummary.vue +++ b/ui/src/views/infra/InfraSummary.vue @@ -147,23 +147,37 @@ - + +
+
+ + + +
+
+ + + + +
+
+
@@ -175,13 +189,15 @@ import router from '@/router' import Breadcrumb from '@/components/widgets/Breadcrumb' import ChartCard from '@/components/widgets/ChartCard' import TooltipLabel from '@/components/widgets/TooltipLabel' +import RackDiagramTab from '@/views/infra/zone/RackDiagramTab.vue' export default { name: 'InfraSummary', components: { Breadcrumb, ChartCard, - TooltipLabel + TooltipLabel, + RackDiagramTab }, data () { return { @@ -351,6 +367,70 @@ export default { white-space: nowrap; overflow: hidden; } + .infra-summary-layout { + display: flex; + align-items: flex-start; + gap: 12px; + } + .summary-cards-pane { + width: 30%; + flex: 0 0 30%; + padding: 10px; + border-radius: 12px; + border: 1px solid #dfe3e8; + background: linear-gradient(180deg, #f8f9fb 0%, #f3f5f8 100%); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65); + } + .rack-visualization-pane { + width: 70%; + flex: 0 0 70%; + min-width: 320px; + } + .summary-mini-card :deep(.ant-card-body) { + padding: 12px 14px 6px !important; + } + .summary-mini-card :deep(.chart-card-content) { + margin-top: -28px; + margin-bottom: 4px; + } + .summary-mini-card :deep(.chart-card-total) { + margin-top: 2px; + font-size: 22px; + line-height: 26px; + height: 26px; + } + .summary-mini-card .chart-card-inner h2 { + margin: 0; + line-height: 1.2; + } + .summary-mini-card .chart-card-inner h2:first-child { + font-size: 20px; + } + .summary-mini-card .chart-card-inner h2:last-child { + margin-top: 4px; + font-size: 28px; + } + .rack-visualization-card :deep(.ant-card-body) { + padding: 10px 12px 12px; + background: #f6f8fb; + border-radius: 10px; + } + .rack-visualization-card { + border: 1px solid #dfe3e8; + border-radius: 12px; + box-shadow: 0 3px 10px rgba(15, 23, 42, 0.05); + } + @media (max-width: 1200px) { + .infra-summary-layout { + flex-direction: column; + } + .summary-cards-pane, + .rack-visualization-pane { + width: 100%; + flex: 1 1 100%; + min-width: 0; + } + } .intermediate-certificate { opacity: 1; transform: none; diff --git a/ui/src/views/infra/zone/RackDiagramTab.vue b/ui/src/views/infra/zone/RackDiagramTab.vue index ca0877316467..b7f13774db1e 100644 --- a/ui/src/views/infra/zone/RackDiagramTab.vue +++ b/ui/src/views/infra/zone/RackDiagramTab.vue @@ -62,7 +62,7 @@
-
+
@@ -102,7 +102,7 @@
-
+
-
+
@@ -133,6 +134,8 @@
@@ -179,36 +182,32 @@ - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + @@ -241,7 +240,14 @@ - + @@ -301,6 +307,20 @@ /> + + + + @@ -326,8 +346,64 @@ placeholder="IP 주소, 용도, 담당자 등을 입력하세요" /> + + + + + +
+
+
+
+ + + + + + + + + +
+

{{ $t('label.no.data') || 'No Data' }}

+
+
+
+
+
+ +
+ +
{{ vm.displayname || vm.name || vm.id }}
+
+
{{ vm.state || '-' }} / {{ vm.ostypename || vm.hypervisor || '-' }}
+
+
+
+
+
@@ -337,6 +413,7 @@ import { ref, reactive, onMounted, computed, watch, onBeforeUnmount, nextTick } import { message } from 'ant-design-vue' import html2canvas from 'html2canvas' import { api } from '@/api' +import { useRouter } from 'vue-router' import { CopyOutlined, SettingOutlined, @@ -344,22 +421,24 @@ import { InfoCircleOutlined, LeftOutlined, RightOutlined, - UpOutlined, - DownOutlined, - VerticalAlignTopOutlined, - VerticalAlignBottomOutlined, PlusOutlined, FileTextOutlined, UploadOutlined, CameraOutlined, SaveOutlined, - ExportOutlined + ExportOutlined, + LinkOutlined, + LaptopOutlined, + AppstoreOutlined, + MoreOutlined, + UnorderedListOutlined } from '@ant-design/icons-vue' // 전역 상태 const loading = ref(false) const saving = ref(false) const zoomLevel = ref(1) +const router = useRouter() // 슬라이더 및 입력창과 연동할 퍼센트 단위 변수 const zoomPercent = computed({ @@ -515,10 +594,27 @@ const exportToImage = async () => { try { message.loading({ content: '이미지 변환 중...', key: 'exporting' }) - // 배율(zoomLevel)에 영향받지 않도록 원본 크기로 캡처 + const fullWidth = element.scrollWidth + const fullHeight = element.scrollHeight + + // 가로 스크롤 영역 전체를 포함해 캡처 const canvas = await html2canvas(element, { backgroundColor: '#eef0f4', - scale: 2 // 고화질 저장 + scale: 2, + width: fullWidth, + height: fullHeight, + windowWidth: fullWidth, + windowHeight: fullHeight, + scrollX: 0, + scrollY: 0, + onclone: (doc) => { + const cloned = doc.querySelector('.rack-container') + if (cloned) { + cloned.style.overflow = 'visible' + cloned.style.width = `${fullWidth}px` + cloned.style.height = `${fullHeight}px` + } + } }) const link = document.createElement('a') @@ -624,14 +720,33 @@ const moveRack = (rIndex, direction) => { // 랙 모달 저장 로직 const submitRackModal = () => { - if (!rackForm.name) { + const rackName = String(rackForm.name || '').trim() + if (!rackName) { message.warning('랙 이름을 입력해주세요.') return } + if (rackName.length > 60) { + message.warning('랙 이름은 60자 이하로 입력해주세요.') + return + } + + if (!Number.isInteger(rackForm.totalHeight) || rackForm.totalHeight < 10 || rackForm.totalHeight > 50) { + message.warning('총 높이(U)는 10~50 사이의 정수만 입력할 수 있습니다.') + return + } + + const duplicateName = parsedRacks.value.some((rack, idx) => { + if (rackModalMode.value === 'edit' && idx === targetRackIndex.value) return false + return String(rack.name || '').trim().toLowerCase() === rackName.toLowerCase() + }) + if (duplicateName) { + message.warning('동일한 랙 이름이 이미 존재합니다.') + return + } if (rackModalMode.value === 'add') { parsedRacks.value.push({ - name: rackForm.name, + name: rackName, totalHeight: rackForm.totalHeight, items: [{ type: 'gap', height: rackForm.totalHeight }] }) @@ -643,7 +758,7 @@ const submitRackModal = () => { if (diff > 0) { // 랙 크기가 커졌으면 맨 아래에 그만큼 여백(Gap) 추가 targetRack.items.push({ type: 'gap', height: diff }) - targetRack.name = rackForm.name + targetRack.name = rackName targetRack.totalHeight = rackForm.totalHeight } else if (diff < 0) { // 랙 크기가 줄어들었을 때의 스마트 처리 로직 @@ -680,11 +795,11 @@ const submitRackModal = () => { } targetRack.items = tempItems - targetRack.name = rackForm.name + targetRack.name = rackName targetRack.totalHeight = rackForm.totalHeight } else { // 높이는 그대로고 이름만 변경된 경우 - targetRack.name = rackForm.name + targetRack.name = rackName } } closeRackModal() @@ -703,9 +818,405 @@ const deviceForm = reactive({ label: '', height: 1, customType: '', - memo: '' + memo: '', + sourceRef: undefined, + quickLinksText: '' }) +const inventoryLoading = ref(false) +const inventoryOptions = ref([]) +const hostCache = ref({}) +const hostVmModalVisible = ref(false) +const hostVmModalTitle = ref('호스트 VM 목록') +const hostVmLoading = ref(false) +const hostVmList = ref([]) +const hostVmFallbackList = ref([]) +const dragSource = ref({ rIndex: -1, iIndex: -1 }) +const quickLinksError = ref('') +const ENABLE_VM_FALLBACK_MOCK = false +const HOST_ACTIVE_VM_STATES = new Set(['running', 'starting', 'stopping', 'migrating']) + +const buildVmMockList = (hostId, hostName = 'sample') => { + const osPool = [ + 'CentOS Linux (Sample)', + 'Rocky Linux (Sample)', + 'Ubuntu Linux (Sample)', + 'Windows Server (Sample)', + 'Windows 11 Pro (Sample)' + ] + const list = [] + for (let i = 1; i <= 20; i++) { + const idx = i - 1 + const no = String(i).padStart(2, '0') + list.push({ + id: `sample-${hostId}-${no}`, + name: `${hostName}-vm-${no}`, + displayname: `${hostName}-vm-${no}`, + state: 'Running', + ostypename: osPool[idx % osPool.length] + }) + } + return list +} + +const filteredHostVmList = computed(() => { + const merged = hostVmList.value.length ? hostVmList.value : hostVmFallbackList.value + return merged.filter(vm => HOST_ACTIVE_VM_STATES.has(String(vm?.state || '').toLowerCase())) +}) + +const mergeAssetMemo = (existingMemo, linesToAdd) => { + const memo = existingMemo || '' + const marker = '[LinkedAsset]' + const cleaned = memo + .split('\n') + .filter(line => !line.startsWith(marker)) + .join('\n') + .trim() + const linked = linesToAdd.map(line => `${marker} ${line}`).join('\n') + return [cleaned, linked].filter(Boolean).join('\n') +} + +const buildInventoryOptions = async () => { + if (!currentZoneId.value) return + inventoryLoading.value = true + const options = [] + + try { + const hostJson = await api('listHosts', { zoneid: currentZoneId.value, listall: true }) + const hosts = hostJson?.listhostsresponse?.host || [] + hosts.forEach(h => { + const name = h.name || h.hostname || h.id + const ip = h.ipaddress ? ` / ${h.ipaddress}` : '' + options.push({ + label: `[Host] ${name}${ip}`, + value: `host:${h.id}`, + meta: { kind: 'host', id: h.id, name, ip: h.ipaddress || '' } + }) + hostCache.value[h.id] = h + }) + } catch (e) { + console.warn('listHosts failed:', e) + } + + inventoryOptions.value = options + inventoryLoading.value = false +} + +const handleSourceChange = (value) => { + if (!value) return + const selected = inventoryOptions.value.find(o => o.value === value) + if (!selected) return + + if (!deviceForm.label || Object.values(defaultLabels).includes(deviceForm.label)) { + deviceForm.label = selected.meta.name + } + + const lines = [ + `Type=${selected.meta.kind}`, + `Name=${selected.meta.name}`, + `Id=${selected.meta.id}` + ] + if (selected.meta.ip) lines.push(`IP=${selected.meta.ip}`) + deviceForm.memo = mergeAssetMemo(deviceForm.memo, lines) +} + +const getLinkedHostId = (item) => { + if (!item?.sourceRef || typeof item.sourceRef !== 'string') return null + if (!item.sourceRef.startsWith('host:')) return null + return item.sourceRef.split(':')[1] || null +} + +const isHostLinked = (item) => { + return !!getLinkedHostId(item) +} + +const parseQuickLinksTextWithValidation = (text) => { + if (!text) return { links: [], errors: [] } + const lines = text.split('\n') + const links = [] + const errors = [] + + lines.forEach((raw, idx) => { + const line = raw.trim() + if (!line) return + + let label = '' + let url = '' + if (line.includes('|')) { + const parts = line.split('|') + label = (parts[0] || '').trim() + url = (parts.slice(1).join('|') || '').trim() + } else { + url = line + } + + if (!url) { + errors.push(`${idx + 1}행: URL이 비어 있습니다. (형식: 이름|URL)`) + return + } + if (!/^https?:\/\//i.test(url)) { + errors.push(`${idx + 1}행: URL은 http:// 또는 https:// 로 시작해야 합니다.`) + return + } + + links.push({ label, url }) + }) + + return { links, errors } +} + +const getQuickLinks = (item) => { + if (!item?.quickLinks || !Array.isArray(item.quickLinks)) return [] + return item.quickLinks.filter(link => link?.url) +} + +const hasQuickLinks = (item) => getQuickLinks(item).length > 0 +const hasActionMenu = (item) => isHostLinked(item) || hasQuickLinks(item) + +const compactGaps = (rack) => { + for (let i = rack.items.length - 1; i > 0; i--) { + if (rack.items[i].type === 'gap' && rack.items[i - 1].type === 'gap') { + rack.items[i - 1].height += rack.items[i].height + rack.items.splice(i, 1) + } + } +} + +const onDragStart = (rIndex, iIndex) => { + dragSource.value = { rIndex, iIndex } +} + +const getDesiredStartUFromRackEvent = (event, rack) => { + if (!event?.currentTarget) return 0 + const rect = event.currentTarget.getBoundingClientRect() + const y = Math.max(0, Math.min(rect.height - 1, event.clientY - rect.top)) + const raw = Math.floor(y / 32) + const rackHeight = rack?.totalHeight || rack.items.reduce((s, i) => s + i.height, 0) + return Math.max(0, Math.min(rackHeight - 1, raw)) +} + +const findBestStartUInRack = (rack, deviceHeight, desiredStartU) => { + compactGaps(rack) + let cursor = 0 + let best = null + for (let i = 0; i < rack.items.length; i++) { + const item = rack.items[i] + if (item.type === 'gap') { + const minStart = cursor + const maxStart = cursor + item.height - deviceHeight + if (maxStart >= minStart) { + const candidate = Math.max(minStart, Math.min(desiredStartU, maxStart)) + const dist = Math.abs(candidate - desiredStartU) + if (!best || dist < best.dist) { + best = { index: i, startU: candidate, dist } + } + } + } + cursor += item.height + } + return best +} + +const placeDeviceAtStartU = (rack, gapIndex, startU, device) => { + let cursor = 0 + for (let i = 0; i < gapIndex; i++) cursor += rack.items[i].height + const gap = rack.items[gapIndex] + if (!gap || gap.type !== 'gap') return false + + const localStart = startU - cursor + const localEnd = localStart + device.height + if (localStart < 0 || localEnd > gap.height) return false + + const before = localStart + const after = gap.height - localEnd + const insert = [] + if (before > 0) insert.push({ type: 'gap', height: before }) + insert.push({ ...device }) + if (after > 0) insert.push({ type: 'gap', height: after }) + rack.items.splice(gapIndex, 1, ...insert) + compactGaps(rack) + return true +} + +const onDropRackFrame = (targetRIndex, event) => { + const { rIndex: sourceRIndex, iIndex: sourceIIndex } = dragSource.value + dragSource.value = { rIndex: -1, iIndex: -1 } + + if (sourceRIndex < 0 || sourceIIndex < 0) return + const sourceRack = parsedRacks.value[sourceRIndex] + const targetRack = parsedRacks.value[targetRIndex] + const sourceItem = sourceRack?.items?.[sourceIIndex] + if (!sourceRack || !targetRack || !sourceItem || sourceItem.type === 'gap') return + + const moving = { ...sourceItem } + const sameRack = sourceRIndex === targetRIndex + + // 원본 위치 비우기 + sourceRack.items.splice(sourceIIndex, 1, { type: 'gap', height: moving.height }) + compactGaps(sourceRack) + + const desiredStartU = getDesiredStartUFromRackEvent(event, targetRack) + const best = findBestStartUInRack(targetRack, moving.height, desiredStartU) + if (!best) { + message.warning(`대상 랙의 연속 여백이 부족합니다. (필요 ${moving.height}U)`) + // 복구 + const restore = findBestStartUInRack(sourceRack, moving.height, 0) + if (restore) placeDeviceAtStartU(sourceRack, restore.index, restore.startU, moving) + return + } + + const placed = placeDeviceAtStartU(targetRack, best.index, best.startU, moving) + if (!placed) { + message.warning('드롭 위치 계산에 실패했습니다. 다시 시도해주세요.') + const restore = findBestStartUInRack(sourceRack, moving.height, 0) + if (restore) placeDeviceAtStartU(sourceRack, restore.index, restore.startU, moving) + return + } + + if (sameRack) compactGaps(targetRack) +} + +const fetchHostById = async (hostId) => { + if (!hostId) return null + if (hostCache.value[hostId]) return hostCache.value[hostId] + const json = await api('listHosts', { id: hostId }) + const host = json?.listhostsresponse?.host?.[0] || null + if (host) hostCache.value[hostId] = host + return host +} + +const goToLinkedHost = (item) => { + const hostId = getLinkedHostId(item) + if (!hostId) return + router.push({ path: `/host/${hostId}` }) +} + +const openLinkedHostOobm = async (item) => { + const hostId = getLinkedHostId(item) + if (!hostId) return + try { + const host = await fetchHostById(hostId) + const protocol = host?.details?.manageconsoleprotocol || 'http' + const address = host?.outofbandmanagement?.address || '' + const port = host?.details?.manageconsoleport + if (!address || !port) { + message.warning('선택한 호스트의 OOBM 포털 정보를 찾을 수 없습니다.') + return + } + window.open(`${protocol}://${address}:${port}`, '_blank') + } catch (e) { + message.error('OOBM 포털 정보를 불러오지 못했습니다.') + } +} + +const openLinkedHostCube = async (item) => { + const hostId = getLinkedHostId(item) + if (!hostId) return + try { + const host = await fetchHostById(hostId) + const ip = host?.ipaddress + if (!ip) { + message.warning('선택한 호스트의 IP 정보를 찾을 수 없습니다.') + return + } + window.open(`https://${ip}:9090`, '_blank') + } catch (e) { + message.error('Cube 포털 정보를 불러오지 못했습니다.') + } +} + +const openLinkedHostVmModal = async (item) => { + const hostId = getLinkedHostId(item) + if (!hostId) return + hostVmLoading.value = true + hostVmModalVisible.value = true + hostVmList.value = [] + hostVmFallbackList.value = [] + + try { + const host = await fetchHostById(hostId) + hostVmModalTitle.value = `호스트 VM 목록 - ${host?.name || host?.hostname || hostId}` + const hostIdStr = String(hostId) + const isVmAssignedToHost = (vm) => { + const vmHostId = String(vm?.hostid || '') + const vmState = String(vm?.state || '').toLowerCase() + // 정책: 현재 host_id가 해당 호스트 + 활성 상태 VM만 표시 + return vmHostId === hostIdStr && HOST_ACTIVE_VM_STATES.has(vmState) + } + + // 1) hostid 직접 조회 + const directJson = await api('listVirtualMachines', { + hostid: hostId, + listall: true, + projectid: '-1', + details: 'min', + pagesize: 500 + }) + let vms = (directJson?.listvirtualmachinesresponse?.virtualmachine || []).filter(isVmAssignedToHost) + + // 2) hostid 조회가 비면 전체에서 host 매핑 기준으로 재탐색 + if (!vms.length) { + const allJson = await api('listVirtualMachines', { + listall: true, + projectid: '-1', + details: 'min', + pagesize: 500 + }) + const allVms = allJson?.listvirtualmachinesresponse?.virtualmachine || [] + vms = allVms.filter(isVmAssignedToHost) + } + + hostVmList.value = vms + + // 개발환경 fallback: host 매핑 샘플 제공 + if (ENABLE_VM_FALLBACK_MOCK && !hostVmList.value.length) { + const hostName = host?.name || host?.hostname || hostId + hostVmFallbackList.value = buildVmMockList(hostId, hostName) + } + } catch (e) { + // 통일된 UI 정책: 실패 시 토스트 없이 No Data 표시 + hostVmList.value = [] + hostVmFallbackList.value = ENABLE_VM_FALLBACK_MOCK + ? buildVmMockList(hostId, 'sample') + : [] + } finally { + hostVmLoading.value = false + } +} + +const getVmOsLogo = (vm) => { + const osname = String(vm?.ostypename || vm?.name || '').toLowerCase() + if (osname.includes('centos')) return 'centos' + if (osname.includes('debian')) return 'debian' + if (osname.includes('ubuntu')) return 'ubuntu' + if (osname.includes('suse')) return 'suse' + if (osname.includes('redhat')) return 'redhat' + if (osname.includes('fedora')) return 'fedora' + if (osname.includes('windows') || osname.includes('dos')) return 'windows' + // Rocky는 전용 브랜드 아이콘이 없어서 Linux 계열로 표현 + if (osname.includes('rocky') || osname.includes('linux')) return 'linux' + if (osname.includes('bsd')) return 'freebsd' + if (osname.includes('apple') || osname.includes('mac')) return 'apple' + return 'linux' +} + +const isRunningVm = (vm) => { + return String(vm?.state || '').toLowerCase() === 'running' +} + +const handleHostActionMenu = (key, item) => { + if (typeof key === 'string' && key.startsWith('quick-')) { + const idx = Number(key.replace('quick-', '')) + const link = getQuickLinks(item)[idx] + if (link?.url) window.open(link.url, '_blank') + return + } + if (key === 'host-detail') goToLinkedHost(item) + if (key === 'host-vms') openLinkedHostVmModal(item) + if (key === 'host-oobm') openLinkedHostOobm(item) + if (key === 'host-cube') openLinkedHostCube(item) +} + // 입력 가능한 최대 높이 사전 계산 (물리적 한계) const maxAllowedHeight = computed(() => { if (targetRackIndex.value === -1 || targetItemIndex.value === -1) return 42 @@ -751,6 +1262,9 @@ const openDeviceModal = (rIndex, iIndex) => { deviceForm.height = 1 deviceForm.customType = '' deviceForm.memo = '' + deviceForm.sourceRef = undefined + deviceForm.quickLinksText = '' + quickLinksError.value = '' } else { // 수정 모드일 때는 기존 데이터 로드 deviceForm.type = item.type @@ -758,6 +1272,12 @@ const openDeviceModal = (rIndex, iIndex) => { deviceForm.height = item.height deviceForm.customType = item.customType || '' deviceForm.memo = item.memo || '' + deviceForm.sourceRef = item.sourceRef || undefined + deviceForm.quickLinksText = getQuickLinks(item).map(link => `${link.label || ''}|${link.url || ''}`.replace(/^\|/, '')).join('\n') + quickLinksError.value = '' + } + if (!inventoryOptions.value.length) { + buildInventoryOptions() } deviceModalVisible.value = true } @@ -783,15 +1303,46 @@ const submitDeviceModal = () => { const rack = parsedRacks.value[rIndex] const oldItem = rack.items[iIndex] - const finalLabel = deviceForm.label || defaultLabels[deviceForm.type] || 'New Device' - const finalCustomType = deviceForm.customType || (deviceForm.type === 'custom' ? 'Custom Unit' : '') + const rawLabel = String(deviceForm.label || '').trim() + const rawCustomType = String(deviceForm.customType || '').trim() + const finalLabel = rawLabel || defaultLabels[deviceForm.type] || 'New Device' + const finalCustomType = rawCustomType || (deviceForm.type === 'custom' ? 'Custom Unit' : '') + + if (deviceForm.type !== 'blank' && !finalLabel) { + message.warning('장비명을 입력해주세요.') + return + } + if (finalLabel.length > 60) { + message.warning('장비명은 60자 이하로 입력해주세요.') + return + } + if (deviceForm.type === 'custom' && !rawCustomType) { + message.warning('커스텀 타입명을 입력해주세요.') + return + } + if (deviceForm.type === 'custom' && rawCustomType.length > 60) { + message.warning('커스텀 타입명은 60자 이하로 입력해주세요.') + return + } + if (!Number.isInteger(deviceForm.height) || deviceForm.height <= 0) { + message.warning('장비 높이(U)는 1 이상의 정수만 입력할 수 있습니다.') + return + } + const quickLinkParsed = parseQuickLinksTextWithValidation(deviceForm.quickLinksText) + if (quickLinkParsed.errors.length > 0) { + quickLinksError.value = quickLinkParsed.errors[0] + return + } + quickLinksError.value = '' const newItem = { type: deviceForm.type, label: finalLabel, height: deviceForm.height, customType: finalCustomType, - memo: deviceForm.memo + memo: deviceForm.memo, + sourceRef: deviceForm.sourceRef || null, + quickLinks: quickLinkParsed.links } // 여백(Gap)에 새 장비 추가 시 @@ -843,132 +1394,40 @@ const cloneItem = (rIndex, iIndex) => { const rack = parsedRacks.value[rIndex] const itemToClone = rack.items[iIndex] const neededHeight = itemToClone.height - - // 1. 아래쪽 여백 확인 - let availableGapBelow = 0 - let gapsToRemoveBelowCount = 0 - - for (let i = iIndex + 1; i < rack.items.length; i++) { - if (rack.items[i].type === 'gap') { - availableGapBelow += rack.items[i].height - gapsToRemoveBelowCount++ - if (availableGapBelow >= neededHeight) break + const newItem = { ...itemToClone } + + const placeIntoGap = (gapIndex, placeAtBottom = false) => { + const gap = rack.items[gapIndex] + if (!gap || gap.type !== 'gap' || gap.height < neededHeight) return false + const remain = gap.height - neededHeight + if (placeAtBottom) { + const insert = [] + if (remain > 0) insert.push({ type: 'gap', height: remain }) + insert.push(newItem) + rack.items.splice(gapIndex, 1, ...insert) } else { - break + rack.items.splice(gapIndex, 1, newItem) + if (remain > 0) rack.items.splice(gapIndex + 1, 0, { type: 'gap', height: remain }) } + return true } - // 아래쪽으로 복제 가능한 경우 - if (availableGapBelow >= neededHeight) { - const remainingGap = availableGapBelow - neededHeight - const newItem = { ...itemToClone } - - // 기존 아래쪽 여백 제거 - rack.items.splice(iIndex + 1, gapsToRemoveBelowCount) - // 복제된 장비 삽입 - rack.items.splice(iIndex + 1, 0, newItem) - // 남은 여백이 있다면 삽입 - if (remainingGap > 0) { - rack.items.splice(iIndex + 2, 0, { type: 'gap', height: remainingGap }) - } - return + // 1) 아래쪽 어디든 충분한 gap 우선 + for (let i = iIndex + 1; i < rack.items.length; i++) { + if (rack.items[i].type === 'gap' && placeIntoGap(i, false)) return } - // 2. 아래쪽이 부족하면 위쪽 여백 확인 - let availableGapAbove = 0 - let gapsToRemoveAboveCount = 0 - + // 2) 위쪽 어디든 충분한 gap (원본 근처에 붙도록 아래쪽 정렬) for (let i = iIndex - 1; i >= 0; i--) { - if (rack.items[i].type === 'gap') { - availableGapAbove += rack.items[i].height - gapsToRemoveAboveCount++ - if (availableGapAbove >= neededHeight) break - } else { - break - } + if (rack.items[i].type === 'gap' && placeIntoGap(i, true)) return } - // 위쪽으로 복제 가능한 경우 - if (availableGapAbove >= neededHeight) { - const remainingGap = availableGapAbove - neededHeight - const newItem = { ...itemToClone } - - // 위쪽 여백이 시작되는 인덱스 계산 - const startGapIndex = iIndex - gapsToRemoveAboveCount - - // 기존 위쪽 여백 제거 - rack.items.splice(startGapIndex, gapsToRemoveAboveCount) - - // 삽입할 아이템 배열 준비 (남은 여백 먼저, 그다음 복제된 장비) - const insertItems = [] - if (remainingGap > 0) { - insertItems.push({ type: 'gap', height: remainingGap }) - } - insertItems.push(newItem) - - // 해당 위치에 일괄 삽입 - rack.items.splice(startGapIndex, 0, ...insertItems) - return + // 3) 그래도 없으면 랙 전체 검사(방어) + for (let i = 0; i < rack.items.length; i++) { + if (rack.items[i].type === 'gap' && placeIntoGap(i, false)) return } - // 3. 위/아래 모두 연속된 빈 공간이 부족한 경우 - message.error(`위/아래 모두 여백이 부족합니다. (필요 공간: ${neededHeight}U)`) -} - -const moveItem = (rIndex, iIndex, direction, isJump = false) => { - const rack = parsedRacks.value[rIndex] - const items = rack.items - - // 🔼 위로 이동 (direction: -1) - if (direction === -1 && iIndex > 0) { - const itemAbove = items[iIndex - 1] - - if (itemAbove.type === 'gap') { - const moveU = isJump ? itemAbove.height : 1 - - itemAbove.height -= moveU - - if (iIndex + 1 < items.length && items[iIndex + 1].type === 'gap') { - items[iIndex + 1].height += moveU - } else { - items.splice(iIndex + 1, 0, { type: 'gap', height: moveU }) - } - - if (itemAbove.height <= 0) { - items.splice(iIndex - 1, 1) - } - } else { - const temp = items[iIndex] - items[iIndex] = items[iIndex - 1] - items[iIndex - 1] = temp - } - } else if (direction === 1 && iIndex < items.length - 1) { - // 🔽 아래로 이동 (direction: 1) - 주석을 괄호 안으로 넣어서 에러 원천 차단! - const itemBelow = items[iIndex + 1] - - if (itemBelow.type === 'gap') { - const moveU = isJump ? itemBelow.height : 1 - - itemBelow.height -= moveU - - let gapInserted = false - if (iIndex > 0 && items[iIndex - 1].type === 'gap') { - items[iIndex - 1].height += moveU - } else { - items.splice(iIndex, 0, { type: 'gap', height: moveU }) - gapInserted = true - } - - const bottomGapIndex = gapInserted ? iIndex + 2 : iIndex + 1 - if (items[bottomGapIndex].height <= 0) { - items.splice(bottomGapIndex, 1) - } - } else { - const temp = items[iIndex] - items[iIndex] = items[iIndex + 1] - items[iIndex + 1] = temp - } - } + message.error(`랙 내에 여유 공간이 부족합니다. (필요 공간: ${neededHeight}U)`) } const deleteItem = (rIndex, iIndex) => { @@ -1175,10 +1634,15 @@ onBeforeUnmount(() => { display: flex; gap: 30px; align-items: flex-start; - overflow-x: auto; + width: max-content; padding-bottom: 20px; /* 가로 스크롤바와 랙 사이 여백 */ } +.rack-zoom-wrapper { + display: inline-block; + width: max-content; +} + /* 개별 랙 래퍼 */ .rack-wrapper { background: #36393f; @@ -1186,8 +1650,8 @@ onBeforeUnmount(() => { padding: 15px; box-shadow: 0 4px 10px rgba(0,0,0,0.3); flex-shrink: 0; - width: 345px; /* 기존 UI 규격에 맞춰 넓이 조정 */ - flex: 0 0 345px + width: 414px; /* 기존 폭 대비 20% 확장 */ + flex: 0 0 414px } /* 랙 헤더 */ @@ -1228,7 +1692,7 @@ onBeforeUnmount(() => { /* 눈금자 기둥 영역 */ .rack-ruler { - width: 24px; /* 눈금자 너비 */ + width: 29px; /* 기존 폭 대비 20% 확장 */ background: #232428; /* 프레임보다 아주 살짝 어두운 색 */ border-right: 2px solid #111; /* 장비 영역과의 경계선 */ display: flex; @@ -1257,7 +1721,7 @@ onBeforeUnmount(() => { /* 랙 내부 프레임 */ .rack-frame { - width: 280px; + width: 336px; /* 기존 폭 대비 20% 확장 */ background: #2c2f35; border: 4px solid #3f434b; /* 프레임 자체 모서리를 둥글게 */ @@ -1484,8 +1948,9 @@ onBeforeUnmount(() => { .device-name-tag { position: absolute; top: 50%; - left: 60%; - transform: translate(-50%, -50%); + left: 56px; + right: 56px; + transform: translateY(-50%); color: #ffffff; font-size: 15px; font-weight: bold; @@ -1493,12 +1958,21 @@ onBeforeUnmount(() => { text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9), 0 0 2px rgba(0, 0, 0, 1); pointer-events: none; display: flex; + justify-content: center; align-items: center; - white-space: nowrap; z-index: 5; letter-spacing: -0.2px; } +.tag-text { + display: inline-block; + max-width: calc(100% - 44px); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: middle; +} + .tag-badge { background: rgba(0,0,0,0.5); padding: 2px 6px; @@ -1573,7 +2047,82 @@ onBeforeUnmount(() => { color: white; } +.device-more-btn { + width: 30px !important; + height: 30px !important; + border-radius: 8px !important; + background: rgba(64, 169, 255, 0.2) !important; +} + +.device-more-btn :deep(.anticon) { + font-size: 18px !important; +} + .device-content:hover .device-actions { opacity: 1; } + +.host-vm-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 12px; +} + +.host-vm-scroll-area { + max-height: 52vh; + overflow-y: auto; + overflow-x: hidden; + padding-right: 4px; +} + +.host-vm-empty-wrap { + min-height: 180px; + display: flex; + align-items: center; + justify-content: center; +} + +.host-vm-card { + border: 1px solid #d9e2ec; + border-radius: 10px; + background: #f9fbff; + padding: 10px 12px; +} + +.host-vm-card-inactive { + background: #f3f4f6; + border-color: #d1d5db; +} + +.host-vm-card-inactive .host-vm-icon { + color: #9ca3af; +} + +.host-vm-card-inactive .host-vm-name { + color: #6b7280; +} + +.host-vm-card-inactive .host-vm-meta { + color: #9ca3af; +} + +.host-vm-icon { + font-size: 22px; + color: #3b82f6; + margin-bottom: 6px; +} + +.host-vm-name { + font-weight: 700; + color: #1f2937; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.host-vm-meta { + margin-top: 4px; + font-size: 12px; + color: #6b7280; +} diff --git a/ui/vue.config.js b/ui/vue.config.js index 9cae2ff66fb1..318ef86aefe8 100644 --- a/ui/vue.config.js +++ b/ui/vue.config.js @@ -138,7 +138,7 @@ const vueConfig = { port: 5050, proxy: { '/client': { - target: process.env.CS_URL || 'http://localhost:8080', + target: process.env.CS_URL || 'http://10.10.218.29:8080', secure: false, ws: false, changeOrigin: true, From f6aec2675923b3764680debbb256b47d1d6a09fd Mon Sep 17 00:00:00 2001 From: stardom3645 Date: Wed, 6 May 2026 11:00:08 +0900 Subject: [PATCH 06/17] =?UTF-8?q?[=EB=9E=99=20=EC=8B=9C=EA=B0=81=ED=99=94]?= =?UTF-8?q?=20=EB=93=9C=EB=9E=98=EA=B7=B8=EB=A1=9C=20=EC=9E=A5=EC=B9=98?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD.=20=EC=9E=A5=EC=B9=98=EB=A7=88=EB=8B=A4=20=EC=BB=A4?= =?UTF-8?q?=EC=8A=A4=ED=85=80=20=EB=A7=81=ED=81=AC=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EA=B0=80=EB=8A=A5.=20=ED=98=B8=EC=8A=A4=ED=8A=B8=EC=9D=98=20?= =?UTF-8?q?=EA=B2=BD=EC=9A=B0=20=EC=9A=B4=EC=98=81=EC=9E=A5=EB=B9=84?= =?UTF-8?q?=EC=99=80=20=EB=A7=A4=EC=B9=AD=ED=95=A0=20=EC=88=98=20=EC=9E=88?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80.?= =?UTF-8?q?=20=ED=98=B8=EC=8A=A4=ED=8A=B8=20=ED=8F=AC=ED=84=B8=20=EB=A7=81?= =?UTF-8?q?=ED=81=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/src/views/infra/zone/RackDiagramTab.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/views/infra/zone/RackDiagramTab.vue b/ui/src/views/infra/zone/RackDiagramTab.vue index b7f13774db1e..e3d47357c972 100644 --- a/ui/src/views/infra/zone/RackDiagramTab.vue +++ b/ui/src/views/infra/zone/RackDiagramTab.vue @@ -833,7 +833,7 @@ const hostVmList = ref([]) const hostVmFallbackList = ref([]) const dragSource = ref({ rIndex: -1, iIndex: -1 }) const quickLinksError = ref('') -const ENABLE_VM_FALLBACK_MOCK = false +const ENABLE_VM_FALLBACK_MOCK = true const HOST_ACTIVE_VM_STATES = new Set(['running', 'starting', 'stopping', 'migrating']) const buildVmMockList = (hostId, hostName = 'sample') => { From 77ea1b699c8e2a48d690b5c0029f83f3a03b4886 Mon Sep 17 00:00:00 2001 From: stardom3645 Date: Fri, 8 May 2026 16:59:38 +0900 Subject: [PATCH 07/17] =?UTF-8?q?[=EB=9E=99=20=EC=8B=9C=EA=B0=81=ED=99=94]?= =?UTF-8?q?=20(1)=EB=8B=A4=EA=B5=AD=EC=96=B4=20=EC=88=98=EC=A0=95=20(2)VM?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=ED=81=B4=EB=A6=AD=EC=8B=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/public/locales/en.json | 115 ++++++++- ui/public/locales/ko_KR.json | 115 ++++++++- ui/src/views/infra/zone/RackDiagramTab.vue | 258 +++++++++++---------- 3 files changed, 366 insertions(+), 122 deletions(-) diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index 7aabda3d9fb8..3df858a0214e 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -5057,5 +5057,118 @@ "message.no.alerts": "There are no alerts to display right now.", "message.solution.description.placeholder": "Enter the solution in Markdown format.", "message.solution.preview.empty": "There is no content to preview.", -"message.solution.summary.placeholder": "Enter a brief summary of the solution." +"message.solution.summary.placeholder": "Enter a brief summary of the solution.", +"rackDiagram.save": "Save", +"rackDiagram.searchPlaceholder": "Search device name, IP, memo...", +"rackDiagram.addRack": "Add Rack", +"rackDiagram.backupJson": "Backup to JSON", +"rackDiagram.backup": "Backup", +"rackDiagram.restoreJson": "Import JSON file", +"rackDiagram.restore": "Restore", +"rackDiagram.capturePng": "Capture PNG image", +"rackDiagram.capture": "Capture", +"rackDiagram.zoom": "Zoom", +"rackDiagram.emptyRack": "No rack is registered. Click [Add Rack] above.", +"rackDiagram.moveLeft": "Move left", +"rackDiagram.moveRight": "Move right", +"rackDiagram.cloneRack": "Clone rack", +"rackDiagram.edit": "Edit", +"rackDiagram.deleteConfirm": "Are you sure you want to delete?", +"rackDiagram.gapAddDevice": "Gap (click to add device)", +"rackDiagram.cloneDevice": "Clone device", +"rackDiagram.moveToOtherRack": "Move to another rack", +"rackDiagram.settings": "Settings", +"rackDiagram.deviceActions": "Device actions", +"rackDiagram.hostDetail": "Host details", +"rackDiagram.hostVmList": "Host VM list", +"rackDiagram.hostOobmPortal": "Open OOBM portal", +"rackDiagram.hostCubePortal": "Open Cube portal", +"rackDiagram.delete": "Delete", +"rackDiagram.editRack": "Edit rack settings", +"rackDiagram.rackName": "Rack name", +"rackDiagram.rackNamePlaceholder": "e.g. Server Rack 1", +"rackDiagram.totalHeight": "Total height (U)", +"rackDiagram.moveDeviceTitle": "Move Device (to another rack)", +"rackDiagram.moveDeviceDesc1": "When you select a target rack, ", +"rackDiagram.moveDeviceDesc2": "it is automatically placed into available gap", +"rackDiagram.moveDeviceDesc3": ".", +"rackDiagram.targetRack": "Target rack", +"rackDiagram.selectRack": "Select a rack", +"rackDiagram.needGapCheck": "remaining gap check needed", +"rackDiagram.deviceConfig": "Device settings", +"rackDiagram.deviceType": "Device type", +"rackDiagram.deviceTypeServer": "Server", +"rackDiagram.deviceTypeSwitch": "Switch", +"rackDiagram.deviceTypeStorage": "Storage", +"rackDiagram.deviceTypeFirewall": "Firewall", +"rackDiagram.deviceTypePatch": "Patch Panel", +"rackDiagram.deviceTypeBlank": "Blank Panel", +"rackDiagram.deviceTypeCustom": "Custom", +"rackDiagram.deviceName": "Device name", +"rackDiagram.deviceNamePlaceholder": "Enter device name.", +"rackDiagram.selectInfraAsset": "Select infra asset", +"rackDiagram.selectHost": "Select host.", +"rackDiagram.customTypeName": "Custom type name", +"rackDiagram.customTypePlaceholder": "e.g. Router", +"rackDiagram.height": "Height (U)", +"rackDiagram.maxAllowedHeightPrefix": "Maximum allowed height at this position is", +"rackDiagram.maxAllowedHeightSuffix": ".", +"rackDiagram.deviceMemo": "Device memo / details", +"rackDiagram.deviceMemoPlaceholder": "Enter IP, purpose, owner, etc.", +"rackDiagram.quickLinks": "Custom shortcut links (one per line, format: Name|URL)", +"rackDiagram.quickLinksPlaceholder": "e.g. iDRAC|https://10.0.0.10\nNAS|https://nas.local", +"rackDiagram.defaultRackName": "Main Rack", +"rackDiagram.copySuffix": "Copy", +"rackDiagram.newDevice": "New Device", +"rackDiagram.customUnit": "Custom Unit", +"rackDiagram.defaultLabelServer": "Server", +"rackDiagram.defaultLabelSwitch": "Switch", +"rackDiagram.defaultLabelStorage": "Storage", +"rackDiagram.defaultLabelFirewall": "Firewall", +"rackDiagram.defaultLabelPatch": "Patch Panel", +"rackDiagram.defaultLabelPdu": "PDU", +"rackDiagram.defaultLabelUps": "UPS", +"rackDiagram.defaultLabelBlank": "Blank Panel", +"rackDiagram.defaultLabelCustom": "Custom Device", +"rackDiagram.msg.noAvailableZone": "No available zone.", +"rackDiagram.msg.zoneLoadFailed": "Failed to load zone information.", +"rackDiagram.msg.rackLoaded": "Rack layout loaded.", +"rackDiagram.msg.rackLoadFailed": "Failed to load rack layout.", +"rackDiagram.msg.noZoneToSave": "No zone information to save.", +"rackDiagram.msg.rackSaved": "Rack layout saved to DB.", +"rackDiagram.msg.rackSaveFailed": "Failed to save rack layout.", +"rackDiagram.msg.noRackToSaveImage": "No rack layout to save.", +"rackDiagram.msg.exportingImage": "Converting image...", +"rackDiagram.msg.imageSaved": "Image saved.", +"rackDiagram.msg.imageSaveFailed": "Failed to save image.", +"rackDiagram.msg.noRackToExport": "No rack data to export.", +"rackDiagram.msg.jsonDownloaded": "JSON file downloaded.", +"rackDiagram.msg.importSuccess": "Data imported successfully.", +"rackDiagram.msg.invalidFileFormat": "Invalid file format (not an array).", +"rackDiagram.msg.jsonParseFailed": "Failed to parse JSON file. It may be corrupted.", +"rackDiagram.msg.rackCloned": "Rack cloned.", +"rackDiagram.msg.enterRackName": "Please enter rack name.", +"rackDiagram.msg.rackNameMax": "Rack name must be 60 characters or less.", +"rackDiagram.msg.rackHeightRange": "Total height must be an integer between 10 and 50.", +"rackDiagram.msg.duplicateRackName": "A rack with the same name already exists.", +"rackDiagram.msg.notEnoughGapOnTarget": "Not enough contiguous gap in target rack. ({height}U needed)", +"rackDiagram.msg.dropCalcFailed": "Failed to calculate drop position. Try again.", +"rackDiagram.msg.oobmNotFound": "OOBM portal info not found for selected host.", +"rackDiagram.msg.oobmLoadFailed": "Failed to load OOBM portal info.", +"rackDiagram.msg.hostIpNotFound": "IP info not found for selected host.", +"rackDiagram.msg.cubeLoadFailed": "Failed to load Cube portal info.", +"rackDiagram.msg.quickLinkUrlEmpty": "Line {line}: URL is empty. (format: Name|URL)", +"rackDiagram.msg.quickLinkUrlInvalid": "Line {line}: URL must start with http:// or https://.", +"rackDiagram.msg.enterDeviceName": "Please enter device name.", +"rackDiagram.msg.deviceNameMax": "Device name must be 60 characters or less.", +"rackDiagram.msg.enterCustomType": "Please enter custom type name.", +"rackDiagram.msg.customTypeMax": "Custom type name must be 60 characters or less.", +"rackDiagram.msg.deviceHeightInteger": "Device height must be an integer of 1 or more.", +"rackDiagram.msg.deviceLargerThanGap": "Cannot add a device larger than selected gap.", +"rackDiagram.msg.notEnoughGapBelow": "Not enough gap below to increase size.", +"rackDiagram.msg.notEnoughSpaceInRack": "Not enough free space in rack. ({height}U needed)", +"rackDiagram.msg.noOtherRackToMove": "No other rack to move to. Add a new rack first.", +"rackDiagram.msg.selectTargetRack": "Select target rack.", +"rackDiagram.msg.targetRackNoSpace": "Cannot move. '{rack}' has no {height}U space.", +"rackDiagram.msg.deviceMoved": "'{label}' moved to '{rack}'." } diff --git a/ui/public/locales/ko_KR.json b/ui/public/locales/ko_KR.json index 88fa690b0336..76eceee45c68 100644 --- a/ui/public/locales/ko_KR.json +++ b/ui/public/locales/ko_KR.json @@ -5059,5 +5059,118 @@ "message.no.alerts": "\ud604\uc7ac \ud45c\uc2dc\ud560 \uacbd\ubcf4\uac00 \uc5c6\uc2b5\ub2c8\ub2e4.", "message.solution.description.placeholder": "\ub9c8\ud06c\ub2e4\uc6b4 \ud615\uc2dd\uc73c\ub85c \ud574\uacb0 \ubc29\uc548\uc744 \uc785\ub825\ud569\ub2c8\ub2e4.", "message.solution.preview.empty": "\ubbf8\ub9ac\ubcf4\uae30\ud560 \ub0b4\uc6a9\uc774 \uc5c6\uc2b5\ub2c8\ub2e4.", -"message.solution.summary.placeholder": "\ud574\uacb0 \ubc29\uc548\uc758 \uac04\ub2e8\ud55c \uc694\uc57d\uc744 \uc785\ub825\ud569\ub2c8\ub2e4." +"message.solution.summary.placeholder": "\ud574\uacb0 \ubc29\uc548\uc758 \uac04\ub2e8\ud55c \uc694\uc57d\uc744 \uc785\ub825\ud569\ub2c8\ub2e4.", +"rackDiagram.save": "\uc800\uc7a5", +"rackDiagram.searchPlaceholder": "\uc7a5\ube44\uba85, IP, \uba54\ubaa8 \uac80\uc0c9...", +"rackDiagram.addRack": "\uc0c8 \ub799 \ucd94\uac00", +"rackDiagram.backupJson": "JSON \ud30c\uc77c\ub85c \ubc31\uc5c5", +"rackDiagram.backup": "\ubc31\uc5c5", +"rackDiagram.restoreJson": "JSON \ud30c\uc77c \ubd88\ub7ec\uc624\uae30", +"rackDiagram.restore": "\ubcf5\uc6d0", +"rackDiagram.capturePng": "PNG \uc774\ubbf8\uc9c0 \ucea1\ucc98", +"rackDiagram.capture": "\ucea1\ucc98", +"rackDiagram.zoom": "Zoom", +"rackDiagram.emptyRack": "\ub4f1\ub85d\ub41c \ub799\uc774 \uc5c6\uc2b5\ub2c8\ub2e4. \uc0c1\ub2e8\uc758 [\uc0c8 \ub799 \ucd94\uac00] \ubc84\ud2bc\uc744 \ub20c\ub7ec\uc8fc\uc138\uc694.", +"rackDiagram.moveLeft": "\uc67c\ucabd \uc774\ub3d9", +"rackDiagram.moveRight": "\uc624\ub978\ucabd \uc774\ub3d9", +"rackDiagram.cloneRack": "\ub799 \ubcf5\uc81c", +"rackDiagram.edit": "\uc218\uc815", +"rackDiagram.deleteConfirm": "\uc0ad\uc81c\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", +"rackDiagram.gapAddDevice": "\uc5ec\ubc31 (\ud074\ub9ad\ud558\uc5ec \uc7a5\ube44 \ucd94\uac00)", +"rackDiagram.cloneDevice": "\uc7a5\ube44 \ubcf5\uc81c", +"rackDiagram.moveToOtherRack": "\ub2e4\ub978 \ub799\uc73c\ub85c \uc774\ub3d9", +"rackDiagram.settings": "\uc124\uc815", +"rackDiagram.deviceActions": "\uc7a5\uce58 \uc791\uc5c5", +"rackDiagram.hostDetail": "\ud638\uc2a4\ud2b8 \uc0c1\uc138", +"rackDiagram.hostVmList": "\ud638\uc2a4\ud2b8 VM \ubaa9\ub85d", +"rackDiagram.hostOobmPortal": "OOBM \ud3ec\ud138 \uc811\uc18d", +"rackDiagram.hostCubePortal": "Cube \ud3ec\ud138 \uc811\uc18d", +"rackDiagram.delete": "\uc0ad\uc81c", +"rackDiagram.editRack": "\ub799 \uc124\uc815 \uc218\uc815", +"rackDiagram.rackName": "\ub799 \uc774\ub984 (Name)", +"rackDiagram.rackNamePlaceholder": "\uc608: Server Rack 1", +"rackDiagram.totalHeight": "\ucd1d \ub192\uc774 (U)", +"rackDiagram.moveDeviceTitle": "\uc7a5\ube44 \uc774\ub3d9 (\ub2e4\ub978 \ub799\uc73c\ub85c \ubcf4\ub0b4\uae30)", +"rackDiagram.moveDeviceDesc1": "\uc774\ub3d9\ud560 \ub300\uc0c1 \ub799\uc744 \uc120\ud0dd\ud558\uba74, ", +"rackDiagram.moveDeviceDesc2": "\ube48 \uacf5\uac04(Gap)\uc744 \ucc3e\uc544 \uc790\ub3d9\uc73c\ub85c \ubc30\uce58", +"rackDiagram.moveDeviceDesc3": "\ub429\ub2c8\ub2e4.", +"rackDiagram.targetRack": "\ub300\uc0c1 \ub799 \uc120\ud0dd", +"rackDiagram.selectRack": "\ub799\uc744 \uc120\ud0dd\ud558\uc138\uc694", +"rackDiagram.needGapCheck": "\uc794\uc5ec \uc5ec\ubc31 \ud655\uc778 \ud544\uc694", +"rackDiagram.deviceConfig": "\uc7a5\ube44 \uc124\uc815", +"rackDiagram.deviceType": "\uc7a5\ube44 \uc885\ub958", +"rackDiagram.deviceTypeServer": "\uc11c\ubc84 (Server)", +"rackDiagram.deviceTypeSwitch": "\uc2a4\uc704\uce58 (Switch)", +"rackDiagram.deviceTypeStorage": "\uc2a4\ud1a0\ub9ac\uc9c0 (Storage)", +"rackDiagram.deviceTypeFirewall": "\ubc29\ud654\ubcbd (Firewall)", +"rackDiagram.deviceTypePatch": "\ud328\uce58\ud328\ub110 (Patch Panel)", +"rackDiagram.deviceTypeBlank": "\ube14\ub7ad\ud06c \ud328\ub110 (Blank)", +"rackDiagram.deviceTypeCustom": "\ucee4\uc2a4\ud140 (Custom)", +"rackDiagram.deviceName": "\uc7a5\ube44\uba85 (Label)", +"rackDiagram.deviceNamePlaceholder": "\uc7a5\ube44\uba85\uc744 \uc785\ub825\ud558\uc138\uc694.", +"rackDiagram.selectInfraAsset": "\uc778\ud504\ub77c \uc790\uc0b0 \uc120\ud0dd", +"rackDiagram.selectHost": "Host\ub97c \uc120\ud0dd\ud558\uc138\uc694.", +"rackDiagram.customTypeName": "\ucee4\uc2a4\ud140 \ud0c0\uc785\uba85", +"rackDiagram.customTypePlaceholder": "\uc608: Router", +"rackDiagram.height": "\ub192\uc774 (U)", +"rackDiagram.maxAllowedHeightPrefix": "\ud604\uc7ac \uc774 \uc704\uce58\uc758 \ucd5c\ub300 \ud5c8\uc6a9 \ub192\uc774\ub294", +"rackDiagram.maxAllowedHeightSuffix": "\uc785\ub2c8\ub2e4.", +"rackDiagram.deviceMemo": "\uc7a5\ube44 \uba54\ubaa8 / \uc0c1\uc138 \uc124\uba85", +"rackDiagram.deviceMemoPlaceholder": "IP \uc8fc\uc18c, \uc6a9\ub3c4, \ub2f4\ub2f9\uc790 \ub4f1\uc744 \uc785\ub825\ud558\uc138\uc694", +"rackDiagram.quickLinks": "\ucee4\uc2a4\ud140 \ubc14\ub85c\uac00\uae30 \ub9c1\ud06c (\ud55c \uc904\uc5d0 \ud558\ub098, \ud615\uc2dd: \uc774\ub984|URL)", +"rackDiagram.quickLinksPlaceholder": "\uc608: iDRAC|https://10.0.0.10\nNAS|https://nas.local", +"rackDiagram.defaultRackName": "Main Rack", +"rackDiagram.copySuffix": "\ubcf5\uc0ac\ubcf8", +"rackDiagram.newDevice": "\uc0c8 \uc7a5\ube44", +"rackDiagram.customUnit": "\ucee4\uc2a4\ud140 \uc720\ub2db", +"rackDiagram.defaultLabelServer": "Server", +"rackDiagram.defaultLabelSwitch": "Switch", +"rackDiagram.defaultLabelStorage": "Storage", +"rackDiagram.defaultLabelFirewall": "Firewall", +"rackDiagram.defaultLabelPatch": "Patch Panel", +"rackDiagram.defaultLabelPdu": "PDU", +"rackDiagram.defaultLabelUps": "UPS", +"rackDiagram.defaultLabelBlank": "Blank Panel", +"rackDiagram.defaultLabelCustom": "Custom Device", +"rackDiagram.msg.noAvailableZone": "\uc0ac\uc6a9 \uac00\ub2a5\ud55c Zone\uc774 \uc5c6\uc2b5\ub2c8\ub2e4.", +"rackDiagram.msg.zoneLoadFailed": "Zone \uc815\ubcf4\ub97c \ubd88\ub7ec\uc624\ub294\ub370 \uc2e4\ud328\ud588\uc2b5\ub2c8\ub2e4.", +"rackDiagram.msg.rackLoaded": "\ub799 \uad6c\uc131\uc744 \ubd88\ub7ec\uc654\uc2b5\ub2c8\ub2e4.", +"rackDiagram.msg.rackLoadFailed": "\ub799 \uad6c\uc131\uc744 \ubd88\ub7ec\uc624\ub294\ub370 \uc2e4\ud328\ud588\uc2b5\ub2c8\ub2e4.", +"rackDiagram.msg.noZoneToSave": "\uc800\uc7a5\ud560 Zone \uc815\ubcf4\uac00 \uc5c6\uc2b5\ub2c8\ub2e4.", +"rackDiagram.msg.rackSaved": "\ub799 \uad6c\uc131\uc774 DB\uc5d0 \uc548\uc804\ud558\uac8c \uc800\uc7a5\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", +"rackDiagram.msg.rackSaveFailed": "\ub799 \uad6c\uc131 \uc800\uc7a5\uc5d0 \uc2e4\ud328\ud588\uc2b5\ub2c8\ub2e4.", +"rackDiagram.msg.noRackToSaveImage": "\uc800\uc7a5\ud560 \ub799 \uad6c\uc131\uc774 \uc5c6\uc2b5\ub2c8\ub2e4.", +"rackDiagram.msg.exportingImage": "\uc774\ubbf8\uc9c0 \ubcc0\ud658 \uc911...", +"rackDiagram.msg.imageSaved": "\uc774\ubbf8\uc9c0 \uc800\uc7a5\uc774 \uc644\ub8cc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", +"rackDiagram.msg.imageSaveFailed": "\uc774\ubbf8\uc9c0 \uc800\uc7a5\uc5d0 \uc2e4\ud328\ud588\uc2b5\ub2c8\ub2e4.", +"rackDiagram.msg.noRackToExport": "\ub0b4\ubcf4\ub0bc \ub799 \ub370\uc774\ud130\uac00 \uc5c6\uc2b5\ub2c8\ub2e4.", +"rackDiagram.msg.jsonDownloaded": "JSON \ud30c\uc77c\uc774 \ub2e4\uc6b4\ub85c\ub4dc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", +"rackDiagram.msg.importSuccess": "\uc131\uacf5\uc801\uc73c\ub85c \ub370\uc774\ud130\ub97c \ubd88\ub7ec\uc654\uc2b5\ub2c8\ub2e4.", +"rackDiagram.msg.invalidFileFormat": "\uc720\ud6a8\ud558\uc9c0 \uc54a\uc740 \ud30c\uc77c \ud615\uc2dd\uc785\ub2c8\ub2e4. (\ubc30\uc5f4\uc774 \uc544\ub2d8)", +"rackDiagram.msg.jsonParseFailed": "JSON \ud30c\uc77c \ud30c\uc2f1\uc5d0 \uc2e4\ud328\ud588\uc2b5\ub2c8\ub2e4. \ud30c\uc77c\uc774 \uc190\uc0c1\ub418\uc5c8\uc744 \uc218 \uc788\uc2b5\ub2c8\ub2e4.", +"rackDiagram.msg.rackCloned": "\ub799\uc774 \ubcf5\uc81c\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", +"rackDiagram.msg.enterRackName": "\ub799 \uc774\ub984\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694.", +"rackDiagram.msg.rackNameMax": "\ub799 \uc774\ub984\uc740 60\uc790 \uc774\ud558\ub85c \uc785\ub825\ud574\uc8fc\uc138\uc694.", +"rackDiagram.msg.rackHeightRange": "\ucd1d \ub192\uc774(U)\ub294 10~50 \uc0ac\uc774\uc758 \uc815\uc218\ub9cc \uc785\ub825\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.", +"rackDiagram.msg.duplicateRackName": "\ub3d9\uc77c\ud55c \ub799 \uc774\ub984\uc774 \uc774\ubbf8 \uc874\uc7ac\ud569\ub2c8\ub2e4.", +"rackDiagram.msg.notEnoughGapOnTarget": "\ub300\uc0c1 \ub799\uc758 \uc5f0\uc18d \uc5ec\ubc31\uc774 \ubd80\uc871\ud569\ub2c8\ub2e4. (\ud544\uc694 {height}U)", +"rackDiagram.msg.dropCalcFailed": "\ub4dc\ub86d \uc704\uce58 \uacc4\uc0b0\uc5d0 \uc2e4\ud328\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", +"rackDiagram.msg.oobmNotFound": "\uc120\ud0dd\ud55c \ud638\uc2a4\ud2b8\uc758 OOBM \ud3ec\ud138 \uc815\ubcf4\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.", +"rackDiagram.msg.oobmLoadFailed": "OOBM \ud3ec\ud138 \uc815\ubcf4\ub97c \ubd88\ub7ec\uc624\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4.", +"rackDiagram.msg.hostIpNotFound": "\uc120\ud0dd\ud55c \ud638\uc2a4\ud2b8\uc758 IP \uc815\ubcf4\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.", +"rackDiagram.msg.cubeLoadFailed": "Cube \ud3ec\ud138 \uc815\ubcf4\ub97c \ubd88\ub7ec\uc624\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4.", +"rackDiagram.msg.quickLinkUrlEmpty": "{line}\ud589: URL\uc774 \ube44\uc5b4 \uc788\uc2b5\ub2c8\ub2e4. (\ud615\uc2dd: \uc774\ub984|URL)", +"rackDiagram.msg.quickLinkUrlInvalid": "{line}\ud589: URL\uc740 http:// \ub610\ub294 https:// \ub85c \uc2dc\uc791\ud574\uc57c \ud569\ub2c8\ub2e4.", +"rackDiagram.msg.enterDeviceName": "\uc7a5\ube44\uba85\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694.", +"rackDiagram.msg.deviceNameMax": "\uc7a5\ube44\uba85\uc740 60\uc790 \uc774\ud558\ub85c \uc785\ub825\ud574\uc8fc\uc138\uc694.", +"rackDiagram.msg.enterCustomType": "\ucee4\uc2a4\ud140 \ud0c0\uc785\uba85\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694.", +"rackDiagram.msg.customTypeMax": "\ucee4\uc2a4\ud140 \ud0c0\uc785\uba85\uc740 60\uc790 \uc774\ud558\ub85c \uc785\ub825\ud574\uc8fc\uc138\uc694.", +"rackDiagram.msg.deviceHeightInteger": "\uc7a5\ube44 \ub192\uc774(U)\ub294 1 \uc774\uc0c1\uc758 \uc815\uc218\ub9cc \uc785\ub825\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.", +"rackDiagram.msg.deviceLargerThanGap": "\uc120\ud0dd\ud55c \uc5ec\ubc31\ubcf4\ub2e4 \ud070 \uc7a5\ube44\ub294 \ucd94\uac00\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.", +"rackDiagram.msg.notEnoughGapBelow": "\uc544\ub798\ucabd \uc5ec\ubc31\uc774 \ubd80\uc871\ud558\uc5ec \ud06c\uae30\ub97c \ub298\ub9b4 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.", +"rackDiagram.msg.notEnoughSpaceInRack": "\ub799 \ub0b4\uc5d0 \uc5ec\uc720 \uacf5\uac04\uc774 \ubd80\uc871\ud569\ub2c8\ub2e4. (\ud544\uc694 \uacf5\uac04: {height}U)", +"rackDiagram.msg.noOtherRackToMove": "\uc774\ub3d9\ud560 \ub2e4\ub978 \ub799\uc774 \uc5c6\uc2b5\ub2c8\ub2e4. [\uc0c8 \ub799 \ucd94\uac00]\ub97c \uba3c\uc800 \ud574\uc8fc\uc138\uc694.", +"rackDiagram.msg.selectTargetRack": "\uc774\ub3d9\ud560 \ub300\uc0c1 \ub799\uc744 \uc120\ud0dd\ud574\uc8fc\uc138\uc694.", +"rackDiagram.msg.targetRackNoSpace": "'{rack}'\uc5d0 {height}U \uacf5\uac04\uc774 \ubd80\uc871\ud558\uc5ec \uc774\ub3d9\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.", +"rackDiagram.msg.deviceMoved": "'{label}' \uc7a5\ube44\uac00 '{rack}'(\uc73c)\ub85c \uc774\ub3d9\ub418\uc5c8\uc2b5\ub2c8\ub2e4." } diff --git a/ui/src/views/infra/zone/RackDiagramTab.vue b/ui/src/views/infra/zone/RackDiagramTab.vue index e3d47357c972..0b9793ef87d7 100644 --- a/ui/src/views/infra/zone/RackDiagramTab.vue +++ b/ui/src/views/infra/zone/RackDiagramTab.vue @@ -5,32 +5,32 @@
- 저장 + {{ t('rackDiagram.save') }} - 새 랙 추가 + {{ t('rackDiagram.addRack') }} - - 백업 + + {{ t('rackDiagram.backup') }} - - 복원 + + {{ t('rackDiagram.restore') }} - - 캡처 + + {{ t('rackDiagram.capture') }} @@ -38,7 +38,7 @@
- Zoom: + {{ t('rackDiagram.zoom') }}:
- +
@@ -81,19 +81,19 @@
- + - + - + - + - + @@ -128,7 +128,7 @@ :style="{ height: (item.height * 32) + 'px' }" >
- + {{ item.height }}U 여백 (클릭하여 장비 추가) + + {{ item.height }}U {{ t('rackDiagram.gapAddDevice') }}
- + - + - + - + - - + + @@ -230,16 +230,16 @@ - - + + - +
- 이동할 대상 랙을 선택하면, 빈 공간(Gap)을 찾아 자동으로 배치됩니다. + {{ t('rackDiagram.moveDeviceDesc1') }} {{ t('rackDiagram.moveDeviceDesc2') }}{{ t('rackDiagram.moveDeviceDesc3') }}
- - + + - {{ rack.name }} (잔여 여백 확인 필요) + {{ rack.name }} ({{ t('rackDiagram.needGapCheck') }}) @@ -279,53 +279,53 @@
- + - 서버 (Server) - 스위치 (Switch) - 스토리지 (Storage) - 방화벽 (Firewall) + {{ t('rackDiagram.deviceTypeServer') }} + {{ t('rackDiagram.deviceTypeSwitch') }} + {{ t('rackDiagram.deviceTypeStorage') }} + {{ t('rackDiagram.deviceTypeFirewall') }} UPS - 패치패널 (Patch Panel) + {{ t('rackDiagram.deviceTypePatch') }} PDU - 블랭크 패널 (Blank) - 커스텀 (Custom) + {{ t('rackDiagram.deviceTypeBlank') }} + {{ t('rackDiagram.deviceTypeCustom') }} - + - + - - + + - +
- 현재 이 위치의 최대 허용 높이는 {{ maxAllowedHeight }}U 입니다. + {{ t('rackDiagram.maxAllowedHeightPrefix') }} {{ maxAllowedHeight }}U {{ t('rackDiagram.maxAllowedHeightSuffix') }}
- + @@ -382,7 +382,7 @@
-

{{ $t('label.no.data') || 'No Data' }}

+

{{ t('label.no.data') }}

@@ -391,6 +391,7 @@ :key="vm.id" class="host-vm-card" :class="{ 'host-vm-card-inactive': !isRunningVm(vm) }" + @click="goToVmDetail(vm)" >
@@ -409,7 +410,7 @@ @@ -1618,6 +2420,14 @@ onBeforeUnmount(() => { align-items: center; } +.toolbar-detail { + flex-wrap: nowrap !important; +} + +.toolbar-detail .toolbar-left { + min-width: 0; +} + /* 버튼 사이 구분선 스타일 */ .toolbar-divider { background-color: #d9d9d9; @@ -1635,11 +2445,208 @@ onBeforeUnmount(() => { /* 랙 캔버스 배경 */ .rack-canvas { + overflow: visible; + min-height: 600px; + padding: 20px; scrollbar-width: thin; scrollbar-color: #888 #eef0f4; background: radial-gradient(circle, #f0f2f5 0%, #e6e9ed 100%); } +.rack-list-view { + min-height: 600px; + padding: 8px 4px 12px; +} + +.rack-list-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 12px; +} + +.rack-list-card { + border-radius: 10px; + position: relative; + transition: opacity 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; +} + +.rack-list-card-match { + position: absolute; + top: 10px; + right: 10px; +} + +.rack-list-card--matched { + border-color: #91caff; + box-shadow: 0 0 0 1px rgba(22, 119, 255, 0.18) inset; +} + +.rack-list-card--dimmed { + opacity: 0.52; +} + +.add-rack-btn { + border-radius: 8px !important; + font-weight: 600; + box-shadow: 0 2px 8px rgba(22, 119, 255, 0.22); +} + +.rack-list-card-title { + font-size: 16px; + font-weight: 700; + color: #1f2937; +} + +.rack-list-card-sub { + margin-top: 2px; + font-size: 13px; + color: #6b7280; +} + +.rack-list-card-usage { + margin-top: 12px; + font-size: 15px; + font-weight: 500; + color: #1f2937; +} + +.rack-list-progress { + margin-top: 10px; +} + +.rack-list-card-usage-detail { + margin-top: 8px; + font-size: 13px; + color: #4b5563; +} + +.rack-list-card-extra { + margin-top: 12px; + font-size: 12px; + color: #64748b; +} + +.rack-detail-layout { + display: flex; + gap: 12px; + align-items: flex-start; + position: relative; + min-width: 0; +} + +.rack-main-pane { + flex: 1 1 auto; + min-width: 0; + overflow-x: auto; + overflow-y: visible; + padding-bottom: 8px; +} + +.rack-side-pane-slot { + width: clamp(300px, 24vw, 420px); + flex: 0 0 clamp(300px, 24vw, 420px); + position: relative; + align-self: flex-start; +} + +.rack-side-pane { + width: 100%; + position: -webkit-sticky; + position: sticky; + top: 64px; + align-self: flex-start; + height: fit-content; + max-height: none; + overflow: visible; + max-width: 100%; + z-index: 20; +} + +.rack-side-pane.is-fixed { + position: fixed !important; + z-index: 40; +} + +.rack-side-pane.is-bottom { + position: absolute !important; + top: auto !important; + left: 0; + right: 0; + bottom: 0; +} + +.rack-side-pane-card :deep(.ant-card-head) { + position: sticky; + top: 0; + z-index: 2; + background: #fff; +} + +.device-info-row { + margin-bottom: 8px; + font-size: 12px; + color: #374151; + word-break: break-word; +} + +.device-info-desc :deep(.ant-descriptions-item-label), +.device-info-desc :deep(.ant-descriptions-item-content) { + font-size: 14px; + line-height: 1.55; +} + +.device-info-desc :deep(.ant-descriptions-item-label) { + width: 100px; + color: #64748b; +} + +.device-info-actions { + display: flex; + gap: 8px; + margin-top: 12px; +} + +.device-info-close-btn { + color: #64748b !important; +} + +.device-info-close-btn:hover { + color: #334155 !important; + background: #f1f5f9 !important; +} + +.device-memo-wrap { + display: flex; + flex-direction: column; + gap: 8px; +} + +.device-memo-plain { + white-space: pre-wrap; + word-break: break-word; + color: #334155; +} + +.device-memo-linked { + background: transparent; + border-radius: 6px; + padding: 0; +} + +.device-memo-linked-title { + display: none; +} + +.device-memo-linked-list, +.device-host-info-list { + display: flex; + flex-direction: column; + gap: 2px; + white-space: pre-wrap; + word-break: break-word; + color: #334155; +} + /* 랙 컨테이너 (가로 정렬) */ .rack-container { display: flex; @@ -1656,21 +2663,22 @@ onBeforeUnmount(() => { /* 개별 랙 래퍼 */ .rack-wrapper { - background: #36393f; - border-radius: 6px; + background: #ffffff; + border: 1px solid #d9e2ec; + border-radius: 10px; padding: 15px; - box-shadow: 0 4px 10px rgba(0,0,0,0.3); + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06); flex-shrink: 0; - width: 414px; /* 기존 폭 대비 20% 확장 */ - flex: 0 0 414px + width: 668px; + flex: 0 0 668px } /* 랙 헤더 */ .rack-header { - color: #fff; + color: #1f2937; font-weight: bold; - margin-bottom: 10px; - padding-bottom: 5px; + margin-bottom: 15px; + padding-bottom: 15px; border-bottom: 2px solid #1890ff; } @@ -1684,12 +2692,42 @@ onBeforeUnmount(() => { height: 24px; /* 높이 고정으로 안정감 부여 */ } +.rack-header-left { + display: flex; + align-items: center; + min-width: 0; + gap: 8px; + flex: 1; +} + +.rack-back-inline-btn { + color: #64748b !important; + height: 28px !important; + width: 28px !important; + min-width: 28px !important; + padding: 0 !important; + border-radius: 999px !important; + border: 1px solid #dbe3ee !important; + background: #fff !important; + display: inline-flex !important; + align-items: center; + justify-content: center; +} + +.rack-back-inline-btn:hover { + color: #1677ff !important; + border-color: #dbeafe !important; + background: #f0f7ff !important; +} + /* 랙 본체 가로 정렬용 */ .rack-body { display: flex; - background: #1a1b1e; /* 전체 프레임 배경과 맞춤 */ + width: 604px; + margin: 0 auto; + background: #f1f5f9; border-radius: 8px; - border: 4px solid #2b2d31; + border: 2px solid #cfd8e3; overflow: hidden; } @@ -1703,38 +2741,38 @@ onBeforeUnmount(() => { /* 눈금자 기둥 영역 */ .rack-ruler { - width: 29px; /* 기존 폭 대비 20% 확장 */ - background: #232428; /* 프레임보다 아주 살짝 어두운 색 */ - border-right: 2px solid #111; /* 장비 영역과의 경계선 */ + width: 44px; + background: #eaf0f7; + border-right: 1px solid #c5cfdb; display: flex; flex-direction: column; } -/* 개별 눈금 텍스트 (정확히 1U = 32px에 맞춤) */ +/* 개별 눈금 텍스트 (정확히 1U = RACK_UNIT_HEIGHT에 맞춤) */ .ruler-number { - height: 32px; + height: 52px; display: flex; align-items: center; justify-content: center; - color: #555c65; - font-size: 11px; + color: #6b7280; + font-size: 14px; font-family: 'Courier New', monospace; font-weight: bold; box-sizing: border-box; /* 눈금선 느낌을 위해 위아래에 아주 연한 점선 추가 */ - border-bottom: 1px dotted rgba(255, 255, 255, 0.05); + border-bottom: 1px dotted rgba(15, 23, 42, 0.12); } /* 맨 위 눈금 상단 선 보정 */ .ruler-number:first-child { - border-top: 1px dotted rgba(255, 255, 255, 0.05); + border-top: 1px dotted rgba(15, 23, 42, 0.12); } /* 랙 내부 프레임 */ .rack-frame { - width: 336px; /* 기존 폭 대비 20% 확장 */ - background: #2c2f35; - border: 4px solid #3f434b; + width: 560px; + background: #f8fbff; + border: 2px solid #cfd8e3; /* 프레임 자체 모서리를 둥글게 */ border-radius: 8px; display: flex; @@ -1750,12 +2788,21 @@ onBeforeUnmount(() => { width: 100%; position: relative; box-sizing: border-box; - /* 장비 사이 틈을 조금 더 부드럽게 처리 */ - border-bottom: 2px solid #0a0a0a; - /* 개별 장비 모서리도 약간 둥글게 */ - border-radius: 4px; + border-bottom: 1px solid #c4cfdd; + border-radius: 0; overflow: hidden; /* 자식 요소의 각진 부분 잘라내기 */ - margin-bottom: -1px; /* 테두리 겹침 보정 */ + margin-bottom: 0; +} + +.rack-item::after { + content: ""; + position: absolute; + left: 0; + right: 0; + bottom: 0; + height: 1px; + background: #c4cfdd; + pointer-events: none; } /* 랙 이름 텍스트: 길어지면 말줄임표 처리 */ @@ -1765,7 +2812,7 @@ onBeforeUnmount(() => { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - font-size: 14px; /* 가독성과 공간 확보를 위한 최적 크기 */ + font-size: 19px; /* 가독성과 공간 확보를 위한 최적 크기 */ font-weight: bold; margin-right: 10px; /* 마우스 오버 시 클릭 가능한 느낌 전달 */ @@ -1775,7 +2822,7 @@ onBeforeUnmount(() => { /* 마우스 호버 시 텍스트 색상을 밝게 변경 */ .rack-name-text:hover { - color: #5865f2 !important; /* 포인트 컬러로 강조 */ + color: #1677ff !important; text-decoration: none; /* 밑줄 추가로 가독성 확보 */ } @@ -1784,6 +2831,10 @@ onBeforeUnmount(() => { margin-bottom: 0; } +.rack-item:last-child::after { + content: none; +} + /* 1. 버튼들을 감싸는 컨테이너를 무조건 가로(Row)로 배치 */ .rack-header-actions { display: flex !important; @@ -1794,25 +2845,29 @@ onBeforeUnmount(() => { /* 2. 개별 버튼 스타일 (inline-flex를 써야 가로 배열이 유지됨) */ .rack-header-actions .ant-btn { - color: #ffffff !important; - padding: 0 4px !important; + color: #334155 !important; + padding: 0 8px !important; + height: 34px !important; + min-width: 34px; display: inline-flex !important; /* flex 대신 inline-flex 적용 */ align-items: center; justify-content: center; background: transparent; - border: none; + border: 1px solid #d8e1ec; + border-radius: 6px; box-shadow: none; } /* 3. 내부 Ant Design 아이콘 색상 강제 (검은색 변함 방지) */ .rack-header-actions .ant-btn :deep(.anticon) { - color: #ffffff !important; + color: #334155 !important; + font-size: 18px !important; } /* 4. 버튼 비활성화 상태 색상 처리 */ .rack-header-actions .ant-btn:disabled, .rack-header-actions .ant-btn:disabled :deep(.anticon) { - color: rgba(255, 255, 255, 0.3) !important; + color: rgba(51, 65, 85, 0.35) !important; } /* 여백 스타일 */ @@ -1824,25 +2879,24 @@ onBeforeUnmount(() => { /* 배경을 더 밝은 회색 계열로 변경 */ background: repeating-linear-gradient( 45deg, - #3a3d42, - #3a3d42 10px, - #42464d 10px, - #42464d 20px + #eef2f7, + #eef2f7 10px, + #e2e8f0 10px, + #e2e8f0 20px ); - /* 텍스트 색상을 더 밝게 하여 가독성 확보 */ - color: #ffffff; - font-size: 12px; + color: #475569; + font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.2s ease; /* 장비와 확실히 구분되도록 안쪽 그림자 제거 혹은 변경 */ - box-shadow: inset 0 0 15px rgba(0,0,0,0.2); + box-shadow: inset 0 0 8px rgba(15, 23, 42, 0.08); } /* 여백 호버 시 피드백 강화 */ .gap-content:hover { - background: #4f545c; - color: #5865f2; + background: #dbe5f2; + color: #1677ff; } /* 장비 내용 래퍼 */ @@ -1851,97 +2905,156 @@ onBeforeUnmount(() => { height: 100%; overflow: hidden; cursor: pointer; - background-color: #232529 !important; - /* 양옆에 랙 마운트용 브라켓(금속 날개) 디자인 추가 */ - border-left: 8px solid #4f545c; - border-right: 8px solid #4f545c; + background: #f8fbff !important; + border: 0; + box-shadow: none; +} + +.device-content::before { + content: none; } /* 1. 상단 컬러 라인 (기존 SVG 상단 라인 대체) */ .device-top-line { position: absolute; - top: 0; left: 0; right: 0; - height: 3px; + top: 0; + left: 0; + right: 0; + height: 2px; + opacity: 0.92; z-index: 2; } /* 2. 질감 패턴 기본 틀 */ .device-pattern { position: absolute; - /* 이름표(text)와 겹치지 않게 오른쪽 구석으로 밀어둠 */ - top: 8px; bottom: 8px; right: 16px; left: 60px; + top: 7px; + bottom: 7px; + right: 12px; + left: 74px; z-index: 1; - opacity: 0.6; + opacity: 1; + border-radius: 6px; +} + +.device-pattern :deep(.device-panel-svg) { + width: 100%; + height: 100%; + display: block; +} + +.device-pattern :deep(.panel-shell) { + fill: #f1f5f9; + stroke: #cfdae6; + stroke-width: 1.2; +} + +.device-pattern :deep(.detail-fill), +.device-pattern :deep(.bay), +.device-pattern :deep(.drive), +.device-pattern :deep(.screen), +.device-pattern :deep(.blade-slot) { + fill: #bfccd9; + stroke: #9aa8ba; + stroke-width: 0.8; +} + +.device-pattern :deep(.detail-soft) { + fill: #d8e1eb; +} + +.device-pattern :deep(.thin), +.device-pattern :deep(.vent), +.device-pattern :deep(.line), +.device-pattern :deep(.block), +.device-pattern :deep(.light), +.device-pattern :deep(.detail-mid) { + fill: #aebccc; +} + +.device-pattern :deep(.port), +.device-pattern :deep(.jack), +.device-pattern :deep(.sfp), +.device-pattern :deep(.key), +.device-pattern :deep(.port-shell), +.device-pattern :deep(.outlet-shell), +.device-pattern :deep(.plug-shell) { + fill: #f8fafc; + stroke: #64748b; + stroke-width: 1; } -/* 💻 서버: 촘촘한 세로 디스크 베이 */ -.pattern-server { - background-image: repeating-linear-gradient(to right, #111 0, #111 6px, transparent 6px, transparent 10px); +.device-pattern :deep(.plug-shell) { + fill: #f4f7fb; + stroke: #7f8da0; + stroke-width: 1.5; } -/* 🗄️ 스토리지: 넓은 세로 디스크 베이 */ -.pattern-storage { - background-image: repeating-linear-gradient(to right, #111 0, #111 16px, transparent 16px, transparent 20px); +.device-pattern :deep(.plug-slot) { + fill: #66758a; + stroke: none; } -/* 🎛️ 스위치: 상하 여백을 줘서 포트 구멍처럼 보이게 */ -.pattern-switch { - top: 35%; bottom: 35%; - background-image: repeating-linear-gradient(to right, #000 0, #000 10px, transparent 10px, transparent 14px); +.device-pattern :deep(.hole), +.device-pattern :deep(.port-hole), +.device-pattern :deep(.detail-dark) { + fill: #475569; + stroke: none; } -/* 🧱 방화벽: 빗살무늬 해치 패턴 */ -.pattern-firewall { - background-image: repeating-linear-gradient(45deg, #111 0, #111 6px, transparent 6px, transparent 12px); +.device-pattern :deep(.dot) { + fill: #64748b; } -/* 🔌 패치패널: 얇은 점선 느낌 */ -.pattern-patch { - top: 45%; bottom: 45%; - background-image: repeating-linear-gradient(to right, #000 0, #000 4px, transparent 4px, transparent 8px); +.device-pattern :deep(.ring), +.device-pattern :deep(.fan), +.device-pattern :deep(.fan-ring) { + fill: none; + stroke: #64748b; + stroke-width: 1.6; } -/* ⚡ UPS: 우측 끝 환풍구 (가운데는 워터마크가 들어감) */ -.pattern-ups { - left: auto; width: 60px; right: 20px; - background-image: repeating-linear-gradient(to right, #111 0, #111 6px, transparent 6px, transparent 12px); +.device-pattern :deep(.stroke), +.device-pattern :deep(.fan-line), +.device-pattern :deep(.detail-line) { + fill: none; + stroke: #94a3b8; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; } -/* 🔋 PDU: 콘센트 구멍 느낌 */ -.pattern-pdu { - background-image: radial-gradient(circle, #000 3px, transparent 4px); - background-size: 16px 16px; - background-position: left center; +.device-pattern :deep(.outlet) { + fill: #f8fafc; + stroke: #64748b; + stroke-width: 1.1; +} + +.device-pattern :deep(.battery) { + fill: #64748b; } /* 🔲 블랭크 패널: 전체를 덮는 연한 사선 (투명도 1) */ .pattern-blank { top: 0; bottom: 0; left: 0; right: 0; - background-image: repeating-linear-gradient(-45deg, #1c1e22 0, #1c1e22 6px, #232529 6px, #232529 12px); + background-image: repeating-linear-gradient(-45deg, #e2e8f0 0, #e2e8f0 6px, #edf2f7 6px, #edf2f7 12px); opacity: 1; } /* 3. UPS 전용 정비율 거대 워터마크 */ .ups-watermark { - position: absolute; - top: 50%; left: 50%; - transform: translate(-50%, -50%); - height: 80%; /* U 높이가 커져도 최대 80%까지만 예쁘게 커짐 */ - max-height: 200px; - z-index: 1; - pointer-events: none; + display: none; } .device-icon-overlay { position: absolute; - left: 14px; + left: 23px; top: 50%; transform: translateY(-50%); - width: 22px; - height: 22px; - /* 0.85 투명도로 존재감을 주고, 그림자로 텍스트와의 간섭 최소화 */ - opacity: 0.85; - filter: drop-shadow(0 0 3px rgba(0,0,0,0.8)); + width: 26px; + height: 26px; + opacity: 0.96; + filter: none; pointer-events: none; display: flex; align-items: center; @@ -1949,6 +3062,10 @@ onBeforeUnmount(() => { z-index: 4; } +.device-icon-overlay::before { + content: none; +} + /* SVG 내부 아이콘이 영역을 꽉 채우도록 설정 */ .device-icon-overlay :deep(svg) { width: 100%; @@ -1959,14 +3076,13 @@ onBeforeUnmount(() => { .device-name-tag { position: absolute; top: 50%; - left: 56px; - right: 56px; + left: 160px; + right: 160px; transform: translateY(-50%); - color: #ffffff; + color: #1f2937; font-size: 15px; font-weight: bold; - /* 배경과 겹쳐도 글씨가 뚜렷하게 보이도록 진한 텍스트 그림자 추가 */ - text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9), 0 0 2px rgba(0, 0, 0, 1); + text-shadow: none; pointer-events: none; display: flex; justify-content: center; @@ -1975,21 +3091,40 @@ onBeforeUnmount(() => { letter-spacing: -0.2px; } +.tag-content { + display: flex; + align-items: center; + justify-content: center; + max-width: 100%; + min-width: 110px; + padding: 3px 9px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.9); + backdrop-filter: none; + -webkit-backdrop-filter: none; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.1); + gap: 8px; +} + .tag-text { - display: inline-block; - max-width: calc(100% - 44px); + display: block; + flex: 1 1 auto; + min-width: 0; + max-width: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - vertical-align: middle; } .tag-badge { - background: rgba(0,0,0,0.5); + background: #1f2937; + color: #ffffff; padding: 2px 6px; border-radius: 10px; - margin-left: 8px; - font-size: 10px; + flex: 0 0 auto; + font-size: 11px; + font-weight: 700; + border: 1px solid rgba(255, 255, 255, 0.18); } /* 액션 버튼 (호버 시에만 표시) */ @@ -1997,7 +3132,7 @@ onBeforeUnmount(() => { /* 버튼을 장비 둥둥 띄우는 핵심 코드 (이게 빠져서 안 보였던 겁니다!) */ position: absolute !important; top: 50%; - bottom: 2px; + bottom: auto; left: 50%; transform: translate(-50%, -50%); z-index: 5; /* 장비나 텍스트 위로 확실히 올림 */ @@ -2006,11 +3141,12 @@ onBeforeUnmount(() => { display: flex !important; justify-content: center; align-items: center; - gap: 1px !important; + gap: 4px !important; background: rgba(0, 0, 0, 0.85) !important; - border-radius: 6px; - padding: 4px 8px; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5); + border-radius: 8px; + padding: 6px 10px; + height: 36px; + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.42); /* 기본적으로는 숨겨둠 (투명도 0, 클릭 방지) */ opacity: 0; @@ -2030,15 +3166,15 @@ onBeforeUnmount(() => { /* 버튼 내부 스타일 및 아이콘 크기 (아까와 동일) */ .device-actions .ant-btn { - padding: 0 4px !important; - height: 26px; + padding: 0 6px !important; + height: 30px; display: inline-flex; align-items: center; justify-content: center; } .device-actions .ant-btn :deep(.anticon) { - font-size: 16px !important; + font-size: 18px !important; color: #ffffff !important; text-shadow: 0 0 1px rgba(255, 255, 255, 0.6); transition: all 0.2s ease; @@ -2046,7 +3182,6 @@ onBeforeUnmount(() => { .device-actions .ant-btn:hover :deep(.anticon) { color: #40a9ff !important; - transform: scale(1.15); } .device-actions .ant-btn:disabled :deep(.anticon) { @@ -2143,4 +3278,164 @@ onBeforeUnmount(() => { font-size: 12px; color: #6b7280; } + +/* dark mode in this project is attached on body */ +.rack-diagram-root.is-dark .toolbar-container { + background: #22282f !important; + border-color: #3e444c !important; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35) !important; +} + +.rack-diagram-root.is-dark .toolbar-divider { + background-color: #4a515a !important; +} + +.rack-diagram-root.is-dark .zoom-label { + color: rgba(255, 255, 255, 0.85) !important; +} + +.rack-diagram-root.is-dark .rack-canvas { + scrollbar-color: #6b7280 #1b2129; + background: radial-gradient(circle, #232a33 0%, #1b2129 100%); +} + +.rack-diagram-root.is-dark .rack-list-card { + background: #1f2732; + border-color: #3a4654; +} + +.rack-diagram-root.is-dark .rack-list-card--matched { + border-color: #4c93ff; + box-shadow: 0 0 0 1px rgba(76, 147, 255, 0.32) inset; +} + +.rack-diagram-root.is-dark .rack-list-card-title { + color: rgba(255, 255, 255, 0.9); +} + +.rack-diagram-root.is-dark .rack-list-card-sub, +.rack-diagram-root.is-dark .rack-list-card-usage-detail, +.rack-diagram-root.is-dark .rack-list-card-extra { + color: rgba(255, 255, 255, 0.65); +} + +.rack-diagram-root.is-dark .rack-list-card-usage { + color: rgba(255, 255, 255, 0.9); +} + +.rack-diagram-root.is-dark .rack-back-inline-btn { + color: rgba(255, 255, 255, 0.72) !important; + border-color: #3a4654 !important; + background: #1f2732 !important; +} + +.rack-diagram-root.is-dark .rack-back-inline-btn:hover { + color: #91caff !important; + border-color: #31445f !important; + background: #273244 !important; +} + +.rack-diagram-root.is-dark .device-info-row { + color: rgba(255, 255, 255, 0.78); +} + +.rack-diagram-root.is-dark .device-info-desc :deep(.ant-descriptions-item-label) { + color: rgba(255, 255, 255, 0.68); +} + +.rack-diagram-root.is-dark .rack-side-pane-card :deep(.ant-card-head) { + background: #1f2732; +} + +.rack-diagram-root.is-dark .device-info-close-btn { + color: rgba(255, 255, 255, 0.7) !important; +} + +.rack-diagram-root.is-dark .device-info-close-btn:hover { + color: rgba(255, 255, 255, 0.92) !important; + background: #273244 !important; +} + +.rack-diagram-root.is-dark .tag-content { + background: rgba(15, 23, 42, 0.52); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35); +} + +@media (max-width: 980px) { + .rack-side-pane-slot { + width: 280px; + flex: 0 0 280px; + } +} + +.rack-diagram-root.is-dark .host-vm-card { + background: #1f2732; + border-color: #3a4654; +} + +.rack-diagram-root.is-dark .host-vm-name { + color: rgba(255, 255, 255, 0.9); +} + +.rack-diagram-root.is-dark .host-vm-meta { + color: rgba(255, 255, 255, 0.65); +} + +.rack-diagram-root.is-dark .toolbar-container :deep(.ant-input), +.rack-diagram-root.is-dark .toolbar-container :deep(.ant-input-number), +.rack-diagram-root.is-dark .toolbar-container :deep(.ant-input-number-input), +.rack-diagram-root.is-dark .toolbar-container :deep(.ant-input-search .ant-input), +.rack-diagram-root.is-dark .toolbar-container :deep(.ant-input-group-addon), +.rack-diagram-root.is-dark .toolbar-container :deep(.ant-slider-rail) { + background: #1a212b !important; + border-color: #4a515a !important; + color: rgba(255, 255, 255, 0.85) !important; +} + +.rack-diagram-root.is-dark .toolbar-container :deep(.ant-input::placeholder), +.rack-diagram-root.is-dark .toolbar-container :deep(.ant-input-number-input::placeholder) { + color: rgba(255, 255, 255, 0.45) !important; +} + +.rack-diagram-root.is-dark .toolbar-container :deep(.ant-input-search-button), +.rack-diagram-root.is-dark .toolbar-container :deep(.ant-slider-track) { + background: #2a8be7 !important; + border-color: #2a8be7 !important; +} + +.rack-diagram-root.is-dark .toolbar-container :deep(.ant-slider-handle) { + border-color: #69c0ff !important; +} + +/* ant-space-item wrappers can override inherited color */ +.rack-diagram-root.is-dark .toolbar-container :deep(.ant-space-item) { + color: rgba(255, 255, 255, 0.88) !important; +} + +.rack-diagram-root.is-dark .toolbar-container :deep(.ant-space-item .ant-btn) { + background: #1a212b !important; + border-color: #4a515a !important; + color: rgba(255, 255, 255, 0.88) !important; +} + +.rack-diagram-root.is-dark .toolbar-container :deep(.ant-space-item .ant-btn > span), +.rack-diagram-root.is-dark .toolbar-container :deep(.ant-space-item .ant-btn .anticon), +.rack-diagram-root.is-dark .toolbar-container :deep(.ant-space-item .ant-btn .anticon svg) { + color: rgba(255, 255, 255, 0.88) !important; + fill: currentColor !important; + -webkit-text-fill-color: rgba(255, 255, 255, 0.88) !important; +} + +.rack-diagram-root.is-dark .toolbar-container :deep(.ant-space-item .ant-btn-primary) { + background: #1677ff !important; + border-color: #1677ff !important; +} + +.rack-diagram-root.is-dark .toolbar-container :deep(.ant-space-item .ant-btn-primary > span), +.rack-diagram-root.is-dark .toolbar-container :deep(.ant-space-item .ant-btn-primary .anticon), +.rack-diagram-root.is-dark .toolbar-container :deep(.ant-space-item .ant-btn-primary .anticon svg) { + color: #ffffff !important; + fill: currentColor !important; + -webkit-text-fill-color: #ffffff !important; +} From eed7ef158f00b97a343cd2bdccdc7fa6b8cef498 Mon Sep 17 00:00:00 2001 From: stardom3645 Date: Fri, 22 May 2026 10:38:40 +0900 Subject: [PATCH 09/17] =?UTF-8?q?[=EB=9E=99=20=EC=8B=9C=EA=B0=81=ED=99=94]?= =?UTF-8?q?=20(1)gen=5Ftoc=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/public/locales/en.json | 15 +- ui/public/locales/ko_KR.json | 15 +- ui/src/views/infra/zone/RackDiagramTab.vue | 179 +++++++++++++++++---- ui/vue.config.js | 2 +- 4 files changed, 172 insertions(+), 39 deletions(-) diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index e37f9159b499..5e2696ad8583 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -5122,8 +5122,15 @@ "rackDiagram.maxAllowedHeightSuffix": ".", "rackDiagram.deviceMemo": "Device memo / details", "rackDiagram.deviceMemoPlaceholder": "Enter IP, purpose, owner, etc.", -"rackDiagram.quickLinks": "Custom shortcut links (one per line, format: Name|URL)", -"rackDiagram.quickLinksPlaceholder": "e.g. iDRAC|https://10.0.0.10\nNAS|https://nas.local", +"rackDiagram.quickLinks": "Custom shortcut links (one per line)", +"rackDiagram.quickLinksHelpText": "Format: LinkName: URL (e.g. iDRAC: https://10.10.12.2:9090, Cube: 10.10.12.3:9090)", +"rackDiagram.quickLinksPlaceholder": "Examples:\niDRAC: https://10.10.12.2:9090\nCube: 10.10.12.3:9090\nNAS|https://nas.local\n(If protocol is omitted, https:// is added automatically)", +"rackDiagram.quickLinkName": "Link name", +"rackDiagram.quickLinkUrl": "URL", +"rackDiagram.quickLinkAction": "Action", +"rackDiagram.quickLinkNamePlaceholder": "e.g. iDRAC", +"rackDiagram.quickLinkUrlPlaceholder": "e.g. 10.10.12.2:9090 or https://10.10.12.2:9090", +"rackDiagram.quickLinkAddRow": "Add link row", "rackDiagram.defaultRackName": "Main Rack", "rackDiagram.copySuffix": "Copy", "rackDiagram.newDevice": "New Device", @@ -5171,8 +5178,8 @@ "rackDiagram.msg.oobmLoadFailed": "Failed to load OOBM portal info.", "rackDiagram.msg.hostIpNotFound": "IP info not found for selected host.", "rackDiagram.msg.cubeLoadFailed": "Failed to load Cube portal info.", -"rackDiagram.msg.quickLinkUrlEmpty": "Line {line}: URL is empty. (format: Name|URL)", -"rackDiagram.msg.quickLinkUrlInvalid": "Line {line}: URL must start with http:// or https://.", +"rackDiagram.msg.quickLinkUrlEmpty": "Line {line}: URL is empty. (Use Name: URL or Name|URL)", +"rackDiagram.msg.quickLinkUrlInvalid": "Line {line}: Invalid URL. (e.g. https://10.10.12.2:9090 or 10.10.12.2:9090)", "rackDiagram.msg.enterDeviceName": "Please enter device name.", "rackDiagram.msg.deviceNameMax": "Device name must be 60 characters or less.", "rackDiagram.msg.enterCustomType": "Please enter custom type name.", diff --git a/ui/public/locales/ko_KR.json b/ui/public/locales/ko_KR.json index aa147a8ec0de..354944539be1 100644 --- a/ui/public/locales/ko_KR.json +++ b/ui/public/locales/ko_KR.json @@ -5124,8 +5124,15 @@ "rackDiagram.maxAllowedHeightSuffix": "\uc785\ub2c8\ub2e4.", "rackDiagram.deviceMemo": "\uc7a5\ube44 \uba54\ubaa8", "rackDiagram.deviceMemoPlaceholder": "IP \uc8fc\uc18c, \uc6a9\ub3c4, \ub2f4\ub2f9\uc790 \ub4f1\uc744 \uc785\ub825\ud558\uc138\uc694", -"rackDiagram.quickLinks": "\ucee4\uc2a4\ud140 \ubc14\ub85c\uac00\uae30 \ub9c1\ud06c (\ud55c \uc904\uc5d0 \ud558\ub098, \ud615\uc2dd: \uc774\ub984|URL)", -"rackDiagram.quickLinksPlaceholder": "\uc608: iDRAC|https://10.0.0.10\nNAS|https://nas.local", +"rackDiagram.quickLinks": "\ucee4\uc2a4\ud140 \ubc14\ub85c\uac00\uae30 \ub9c1\ud06c", +"rackDiagram.quickLinksHelpText": "\uc608: iDRAC: https://10.10.10.10:9090, Cube: 10.10.10.10:9090", +"rackDiagram.quickLinksPlaceholder": "\uc608\uc2dc:\niDRAC: https://10.10.10.10:9090\nCube: 10.10.10.10:9090\nNAS|https://nas.local\n(\ud504\ub85c\ud1a0\ucf5c\uc744 \uc0dd\ub7b5\ud558\uba74 https://\uac00 \uc790\ub3d9 \ucd94\uac00\ub429\ub2c8\ub2e4)", +"rackDiagram.quickLinkName": "\ub9c1\ud06c \uc774\ub984", +"rackDiagram.quickLinkUrl": "URL", +"rackDiagram.quickLinkAction": "\uc791\uc5c5", +"rackDiagram.quickLinkNamePlaceholder": "\uc608: iDRAC", +"rackDiagram.quickLinkUrlPlaceholder": "\uc608: 10.10.12.2:9090 \ub610\ub294 https://10.10.12.2:9090", +"rackDiagram.quickLinkAddRow": "\ub9c1\ud06c \ud589 \ucd94\uac00", "rackDiagram.defaultRackName": "Main Rack", "rackDiagram.copySuffix": "\ubcf5\uc0ac\ubcf8", "rackDiagram.newDevice": "\uc0c8 \uc7a5\ube44", @@ -5173,8 +5180,8 @@ "rackDiagram.msg.oobmLoadFailed": "OOBM \ud3ec\ud138 \uc815\ubcf4\ub97c \ubd88\ub7ec\uc624\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4.", "rackDiagram.msg.hostIpNotFound": "\uc120\ud0dd\ud55c \ud638\uc2a4\ud2b8\uc758 IP \uc815\ubcf4\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.", "rackDiagram.msg.cubeLoadFailed": "Cube \ud3ec\ud138 \uc815\ubcf4\ub97c \ubd88\ub7ec\uc624\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4.", -"rackDiagram.msg.quickLinkUrlEmpty": "{line}\ud589: URL\uc774 \ube44\uc5b4 \uc788\uc2b5\ub2c8\ub2e4. (\ud615\uc2dd: \uc774\ub984|URL)", -"rackDiagram.msg.quickLinkUrlInvalid": "{line}\ud589: URL\uc740 http:// \ub610\ub294 https:// \ub85c \uc2dc\uc791\ud574\uc57c \ud569\ub2c8\ub2e4.", +"rackDiagram.msg.quickLinkUrlEmpty": "{line}\ud589: URL\uc774 \ube44\uc5b4 \uc788\uc2b5\ub2c8\ub2e4. (\uc608: \uc774\ub984: URL \ub610\ub294 \uc774\ub984|URL)", +"rackDiagram.msg.quickLinkUrlInvalid": "{line}\ud589: URL \ud615\uc2dd\uc774 \uc62c\ubc14\ub974\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4. (\uc608: https://10.10.12.2:9090 \ub610\ub294 10.10.12.2:9090)", "rackDiagram.msg.enterDeviceName": "\uc7a5\ube44\uba85\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694.", "rackDiagram.msg.deviceNameMax": "\uc7a5\ube44\uba85\uc740 60\uc790 \uc774\ud558\ub85c \uc785\ub825\ud574\uc8fc\uc138\uc694.", "rackDiagram.msg.enterCustomType": "\ucee4\uc2a4\ud140 \ud0c0\uc785\uba85\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694.", diff --git a/ui/src/views/infra/zone/RackDiagramTab.vue b/ui/src/views/infra/zone/RackDiagramTab.vue index 47f170c611b1..844cd68bd651 100644 --- a/ui/src/views/infra/zone/RackDiagramTab.vue +++ b/ui/src/views/infra/zone/RackDiagramTab.vue @@ -76,6 +76,34 @@ ]" @click="openRackDetail(idx)" > +
+ + + + + + +
{{ t('rackDiagram.searchMatched', { count: getRackMatchCount(rack) }) }}
@@ -451,12 +479,46 @@ :validateStatus="quickLinksError ? 'error' : ''" :help="quickLinksError" > - + @@ -1225,8 +1287,7 @@ const deviceForm = reactive({ height: 1, customType: '', memo: '', - sourceRef: undefined, - quickLinksText: '' + sourceRef: undefined }) const inventoryLoading = ref(false) @@ -1239,6 +1300,28 @@ const hostVmList = ref([]) const hostVmFallbackList = ref([]) const dragSource = ref({ rIndex: -1, iIndex: -1 }) const quickLinksError = ref('') +const quickLinkRows = ref([]) +const quickLinkColumns = computed(() => ([ + { title: t('rackDiagram.quickLinkName'), key: 'label', dataIndex: 'label', width: '35%' }, + { title: t('rackDiagram.quickLinkUrl'), key: 'url', dataIndex: 'url' }, + { title: t('rackDiagram.quickLinkAction'), key: 'action', dataIndex: 'action', width: 56 } +])) + +const makeQuickLinkRow = (label = '', url = '') => ({ + key: `${Date.now()}-${Math.random().toString(16).slice(2)}`, + label, + url +}) + +const addQuickLinkRow = () => { + quickLinkRows.value.push(makeQuickLinkRow()) +} + +const removeQuickLinkRow = (index) => { + quickLinkRows.value.splice(index, 1) + if (!quickLinkRows.value.length) quickLinkRows.value.push(makeQuickLinkRow()) +} +// 샘플 VM 목록 const ENABLE_VM_FALLBACK_MOCK = true const HOST_ACTIVE_VM_STATES = new Set(['running', 'starting', 'stopping', 'migrating']) @@ -1336,36 +1419,35 @@ const isHostLinked = (item) => { return !!getLinkedHostId(item) } -const parseQuickLinksTextWithValidation = (text) => { - if (!text) return { links: [], errors: [] } - const lines = text.split('\n') +const parseQuickLinksRowsWithValidation = (rows) => { + if (!rows || !rows.length) return { links: [], errors: [] } const links = [] const errors = [] - lines.forEach((raw, idx) => { - const line = raw.trim() - if (!line) return - - let label = '' - let url = '' - if (line.includes('|')) { - const parts = line.split('|') - label = (parts[0] || '').trim() - url = (parts.slice(1).join('|') || '').trim() - } else { - url = line - } + rows.forEach((row, idx) => { + const label = String(row?.label || '').trim() + const rawUrl = String(row?.url || '').trim() + if (!label && !rawUrl) return - if (!url) { + if (!rawUrl) { errors.push(t('rackDiagram.msg.quickLinkUrlEmpty', { line: idx + 1 })) return } - if (!/^https?:\/\//i.test(url)) { + + // 스키마를 생략한 입력도 허용 (예: 10.10.12.2:9090) + const normalizedUrl = /^https?:\/\//i.test(rawUrl) ? rawUrl : `https://${rawUrl}` + let parsed = null + try { + parsed = new URL(normalizedUrl) + } catch (e) { + parsed = null + } + if (!parsed || !/^https?:$/i.test(parsed.protocol)) { errors.push(t('rackDiagram.msg.quickLinkUrlInvalid', { line: idx + 1 })) return } - links.push({ label, url }) + links.push({ label, url: normalizedUrl }) }) return { links, errors } @@ -1685,7 +1767,7 @@ const openDeviceModal = (rIndex, iIndex) => { deviceForm.customType = '' deviceForm.memo = '' deviceForm.sourceRef = undefined - deviceForm.quickLinksText = '' + quickLinkRows.value = [makeQuickLinkRow()] quickLinksError.value = '' } else { // 수정 모드일 때는 기존 데이터 로드 @@ -1695,7 +1777,9 @@ const openDeviceModal = (rIndex, iIndex) => { deviceForm.customType = item.customType || '' deviceForm.memo = item.memo || '' deviceForm.sourceRef = item.sourceRef || undefined - deviceForm.quickLinksText = getQuickLinks(item).map(link => `${link.label || ''}|${link.url || ''}`.replace(/^\|/, '')).join('\n') + quickLinkRows.value = getQuickLinks(item).length + ? getQuickLinks(item).map(link => makeQuickLinkRow(link.label || '', link.url || '')) + : [makeQuickLinkRow()] quickLinksError.value = '' } if (!inventoryOptions.value.length) { @@ -1753,7 +1837,7 @@ const submitDeviceModal = () => { message.warning(t('rackDiagram.msg.deviceHeightInteger')) return } - const quickLinkParsed = parseQuickLinksTextWithValidation(deviceForm.quickLinksText) + const quickLinkParsed = parseQuickLinksRowsWithValidation(quickLinkRows.value) if (quickLinkParsed.errors.length > 0) { quickLinksError.value = quickLinkParsed.errors[0] return @@ -2470,6 +2554,22 @@ onBeforeUnmount(() => { transition: opacity 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; } +.rack-list-card-actions { + position: absolute; + top: 8px; + right: 8px; + z-index: 2; +} + +.rack-list-more-btn { + color: #64748b !important; +} + +.rack-list-more-btn:hover { + color: #334155 !important; + background: #f1f5f9 !important; +} + .rack-list-card-match { position: absolute; top: 10px; @@ -2647,6 +2747,16 @@ onBeforeUnmount(() => { color: #334155; } +.quick-links-editor { + display: flex; + flex-direction: column; + gap: 8px; +} + +.quick-links-add-btn { + align-self: flex-start; +} + /* 랙 컨테이너 (가로 정렬) */ .rack-container { display: flex; @@ -3309,6 +3419,15 @@ onBeforeUnmount(() => { box-shadow: 0 0 0 1px rgba(76, 147, 255, 0.32) inset; } +.rack-diagram-root.is-dark .rack-list-more-btn { + color: rgba(255, 255, 255, 0.72) !important; +} + +.rack-diagram-root.is-dark .rack-list-more-btn:hover { + color: rgba(255, 255, 255, 0.9) !important; + background: #273244 !important; +} + .rack-diagram-root.is-dark .rack-list-card-title { color: rgba(255, 255, 255, 0.9); } diff --git a/ui/vue.config.js b/ui/vue.config.js index 318ef86aefe8..9cae2ff66fb1 100644 --- a/ui/vue.config.js +++ b/ui/vue.config.js @@ -138,7 +138,7 @@ const vueConfig = { port: 5050, proxy: { '/client': { - target: process.env.CS_URL || 'http://10.10.218.29:8080', + target: process.env.CS_URL || 'http://localhost:8080', secure: false, ws: false, changeOrigin: true, From 50eecbd9dd115fc1b2fb0b1fd3b062d68b5afe95 Mon Sep 17 00:00:00 2001 From: stardom3645 Date: Fri, 22 May 2026 10:38:45 +0900 Subject: [PATCH 10/17] =?UTF-8?q?[=EB=9E=99=20=EC=8B=9C=EA=B0=81=ED=99=94]?= =?UTF-8?q?=20(1)gen=5Ftoc=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tools/apidoc/gen_toc.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tools/apidoc/gen_toc.py b/tools/apidoc/gen_toc.py index 3b361886c782..442f37383fb9 100644 --- a/tools/apidoc/gen_toc.py +++ b/tools/apidoc/gen_toc.py @@ -305,7 +305,9 @@ 'listVhbaDevices.xml': 'Storage', 'listHostScsiDevices.xml': 'Storage', 'updateHostScsiDevices.xml': 'Storage', - 'deleteVhbaDevice.xml': 'Storage' + 'deleteVhbaDevice.xml': 'Storage', + 'listRackLayouts.xml': 'Rack Management', + 'updateRackLayout.xml': 'Rack Management' } From 0f04d9f4680076d20415152b020b97faa61caf91 Mon Sep 17 00:00:00 2001 From: stardom3645 Date: Tue, 26 May 2026 09:27:48 +0900 Subject: [PATCH 11/17] =?UTF-8?q?[=EB=9E=99=20=EC=8B=9C=EA=B0=81=ED=99=94]?= =?UTF-8?q?=20=EB=9E=99=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EC=95=84=EC=9D=B4=EC=BD=98=20=ED=8C=8C=EC=9D=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/public/locales/en.json | 5 + ui/public/locales/ko_KR.json | 5 + ui/src/views/infra/zone/RackDiagramTab.vue | 426 ++++++++++++------ .../zone/components/RackListCardIcon.vue | 74 +++ 4 files changed, 376 insertions(+), 134 deletions(-) create mode 100644 ui/src/views/infra/zone/components/RackListCardIcon.vue diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index 5e2696ad8583..d6420291ba70 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -5068,6 +5068,8 @@ "rackDiagram.capturePng": "Capture PNG image", "rackDiagram.capture": "Capture", "rackDiagram.zoom": "Zoom", +"rackDiagram.backToList": "Back to list", +"rackDiagram.list": "List", "rackDiagram.emptyRack": "No rack is registered. Click [Add Rack] above.", "rackDiagram.moveLeft": "Move left", "rackDiagram.moveRight": "Move right", @@ -5087,6 +5089,9 @@ "rackDiagram.editRack": "Edit rack settings", "rackDiagram.rackName": "Rack name", "rackDiagram.rackNamePlaceholder": "e.g. Server Rack 1", +"rackDiagram.rackLocation": "Rack location", +"rackDiagram.rackLocationPlaceholder": "e.g. 3F / Server Room A", +"rackDiagram.rackSpec": "Spec", "rackDiagram.totalHeight": "Total height (U)", "rackDiagram.moveDeviceTitle": "Move Device (to another rack)", "rackDiagram.moveDeviceDesc1": "When you select a target rack, ", diff --git a/ui/public/locales/ko_KR.json b/ui/public/locales/ko_KR.json index 354944539be1..887198863e02 100644 --- a/ui/public/locales/ko_KR.json +++ b/ui/public/locales/ko_KR.json @@ -5070,6 +5070,8 @@ "rackDiagram.capturePng": "PNG \uc774\ubbf8\uc9c0 \ucea1\ucc98", "rackDiagram.capture": "\ucea1\ucc98", "rackDiagram.zoom": "Zoom", +"rackDiagram.backToList": "\ubaa9\ub85d\uc73c\ub85c", +"rackDiagram.list": "\ubaa9\ub85d", "rackDiagram.emptyRack": "\ub4f1\ub85d\ub41c \ub799\uc774 \uc5c6\uc2b5\ub2c8\ub2e4. \uc0c1\ub2e8\uc758 [\uc0c8 \ub799 \ucd94\uac00] \ubc84\ud2bc\uc744 \ub20c\ub7ec\uc8fc\uc138\uc694.", "rackDiagram.moveLeft": "\uc67c\ucabd \uc774\ub3d9", "rackDiagram.moveRight": "\uc624\ub978\ucabd \uc774\ub3d9", @@ -5089,6 +5091,9 @@ "rackDiagram.editRack": "\ub799 \uc124\uc815 \uc218\uc815", "rackDiagram.rackName": "\ub799 \uc774\ub984 (Name)", "rackDiagram.rackNamePlaceholder": "\uc608: Server Rack 1", +"rackDiagram.rackLocation": "\ub799 \uc704\uce58", +"rackDiagram.rackLocationPlaceholder": "\uc608: 3F / Server Room A", +"rackDiagram.rackSpec": "\uaddc\uaca9", "rackDiagram.totalHeight": "\ucd1d \ub192\uc774 (U)", "rackDiagram.moveDeviceTitle": "\uc7a5\ube44 \uc774\ub3d9 (\ub2e4\ub978 \ub799\uc73c\ub85c \ubcf4\ub0b4\uae30)", "rackDiagram.moveDeviceDesc1": "\uc774\ub3d9\ud560 \ub300\uc0c1 \ub799\uc744 \uc120\ud0dd\ud558\uba74, ", diff --git a/ui/src/views/infra/zone/RackDiagramTab.vue b/ui/src/views/infra/zone/RackDiagramTab.vue index 844cd68bd651..31d66d49694e 100644 --- a/ui/src/views/infra/zone/RackDiagramTab.vue +++ b/ui/src/views/infra/zone/RackDiagramTab.vue @@ -107,21 +107,33 @@
{{ t('rackDiagram.searchMatched', { count: getRackMatchCount(rack) }) }}
-
{{ rack.name }}
-
{{ rack.totalHeight }}U {{ t('rackDiagram.rackUnit') }}
-
{{ t('rackDiagram.usage') }} {{ getRackUsagePercent(rack) }}%
- -
- {{ getRackUsedU(rack) }}U {{ t('rackDiagram.used') }} / {{ rack.totalHeight }}U {{ t('rackDiagram.total') }} -
-
- {{ t('rackDiagram.free') }} {{ getRackFreeU(rack) }}U · {{ t('rackDiagram.deviceCount') }} {{ getRackDeviceCount(rack) }}{{ t('rackDiagram.countSuffix') }} +
+ +
+
{{ rack.name }}
+
{{ t('label.created') }} {{ getRackCreatedDate(rack) }}
+
+ + {{ t('rackDiagram.rackLocation') }} {{ getRackLocation(rack) }} + +
+
{{ t('rackDiagram.usage') }} {{ getRackUsagePercent(rack) }}%
+ +
+ {{ getRackUsedU(rack) }}U {{ t('rackDiagram.used') }} / {{ rack.totalHeight }}U {{ t('rackDiagram.total') }} +
+
+ {{ t('rackDiagram.free') }} {{ getRackFreeU(rack) }}U · {{ t('rackDiagram.deviceCount') }} {{ getRackDeviceCount(rack) }}{{ t('rackDiagram.countSuffix') }} +
+
@@ -137,37 +149,67 @@
-
-
- - - - - +
+
- + {{ rack.name }} ({{ rack.totalHeight }}U)
- - - - - - - - - - - +
+ + + + + +
+ +
+ + + + + + + + + + + + + + + + + +
+
+ + {{ getRackUsedU(rack) }}U / {{ rack.totalHeight }}U + + · + + + {{ t('label.created') }} {{ getRackCreatedDate(rack) }} + + · + + + + + {{ t('rackDiagram.rackLocation') }} {{ getRackLocation(rack) }} + + + +
@@ -226,7 +268,7 @@ v-html="renderBadgeIcon(getDevicePanelType(item))" >
-
+
{{ item.label }} {{ item.customType }} @@ -360,6 +402,12 @@ + + + + + + { rackModalMode.value = mode targetRackIndex.value = index if (mode === 'edit' && index > -1) { - rackForm.name = parsedRacks.value[index].name - rackForm.totalHeight = parsedRacks.value[index].totalHeight + const rack = parsedRacks.value[index] + rackForm.name = rack.name + rackForm.totalHeight = rack.totalHeight + rackForm.location = rack.location || rack.room || rack.position || '' + rackForm.createdAt = rack.createdAt || rack.created || rack.createDate || '' } else { rackForm.name = '' rackForm.totalHeight = 42 + rackForm.location = '' + rackForm.createdAt = new Date().toISOString() } rackModalVisible.value = true } @@ -1070,6 +1126,9 @@ const cloneRack = (rIndex) => { const newRack = { ...targetRack, name: `${targetRack.name} (${t('rackDiagram.copySuffix')})`, + createdAt: new Date().toISOString(), + created: undefined, + createDate: undefined, items: targetRack.items.map(item => ({ ...item })) } parsedRacks.value.push(newRack) @@ -1079,6 +1138,7 @@ const cloneRack = (rIndex) => { // 랙 모달 저장 로직 const submitRackModal = () => { const rackName = String(rackForm.name || '').trim() + const rackLocation = String(rackForm.location || '').trim() if (!rackName) { message.warning(t('rackDiagram.msg.enterRackName')) return @@ -1106,6 +1166,8 @@ const submitRackModal = () => { parsedRacks.value.push({ name: rackName, totalHeight: rackForm.totalHeight, + location: rackLocation, + createdAt: rackForm.createdAt || new Date().toISOString(), items: [{ type: 'gap', height: rackForm.totalHeight }] }) } else { @@ -1117,6 +1179,7 @@ const submitRackModal = () => { // 랙 크기가 커졌으면 맨 아래에 그만큼 여백(Gap) 추가 targetRack.items.push({ type: 'gap', height: diff }) targetRack.name = rackName + targetRack.location = rackLocation targetRack.totalHeight = rackForm.totalHeight } else if (diff < 0) { // 랙 크기가 줄어들었을 때의 스마트 처리 로직 @@ -1154,10 +1217,12 @@ const submitRackModal = () => { targetRack.items = tempItems targetRack.name = rackName + targetRack.location = rackLocation targetRack.totalHeight = rackForm.totalHeight } else { // 높이는 그대로고 이름만 변경된 경우 targetRack.name = rackName + targetRack.location = rackLocation } } closeRackModal() @@ -1239,6 +1304,17 @@ const getRackUsagePercent = (rack) => { if (!total) return 0 return Math.round((getRackUsedU(rack) / total) * 100) } +const formatRackDate = (value) => { + if (!value) return '-' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return String(value) + const yyyy = date.getFullYear() + const mm = String(date.getMonth() + 1).padStart(2, '0') + const dd = String(date.getDate()).padStart(2, '0') + return `${yyyy}.${mm}.${dd}` +} +const getRackCreatedDate = (rack) => formatRackDate(rack?.createdAt || rack?.created || rack?.createDate) +const getRackLocation = (rack) => rack?.location || rack?.room || rack?.position || '-' const searchQuery = ref('') const hasSearchQuery = computed(() => !!searchQuery.value?.trim()) @@ -2544,16 +2620,21 @@ onBeforeUnmount(() => { .rack-list-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; } .rack-list-card { border-radius: 10px; position: relative; + min-height: 218px; transition: opacity 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; } +.rack-list-card :deep(.ant-card-body) { + height: 100%; +} + .rack-list-card-actions { position: absolute; top: 8px; @@ -2595,12 +2676,63 @@ onBeforeUnmount(() => { font-size: 16px; font-weight: 700; color: #1f2937; + line-height: 1.35; + min-height: 22px; + max-width: 100%; + padding-right: 28px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rack-list-card-body { + display: flex; + align-items: flex-start; + gap: 0; +} + +.rack-list-card-rack-icon { + width: 59px; + height: 59px; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 59px; + margin-right: 12px; + color: #1f2937; } -.rack-list-card-sub { - margin-top: 2px; +.rack-list-card-rack-icon svg { + width: 100%; + height: 100%; +} + +.rack-list-card-content { + flex: 1; + min-width: 0; + padding-right: 34px; +} + +.rack-list-card-meta { + margin-top: 4px; font-size: 13px; color: #6b7280; + display: block; + min-width: 0; +} + +.rack-list-card-meta-location-row { + margin-top: 2px; +} + +.rack-list-card-location { + display: inline-block; + width: 100%; + max-width: 100%; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .rack-list-card-usage { @@ -2785,49 +2917,139 @@ onBeforeUnmount(() => { /* 랙 헤더 */ .rack-header { - color: #1f2937; - font-weight: bold; - margin-bottom: 15px; - padding-bottom: 15px; - border-bottom: 2px solid #1890ff; + color: #111827; + margin-bottom: 24px; + padding: 16px 16px 14px; + border-radius: 12px; + background: #fff; + border: 1px solid #eef2f7; + border-bottom: 1px solid #1677ff; + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.05); } -.rack-header-inner { +.rack-header-top { display: flex; - flex-direction: row; - flex-wrap: nowrap; /* 절대 줄바꿈 금지 */ - align-items: center; justify-content: space-between; - width: 100%; - height: 24px; /* 높이 고정으로 안정감 부여 */ + gap: 12px; + align-items: flex-start; } -.rack-header-left { +.rack-header-title-group { display: flex; align-items: center; + flex: 1 1 auto; + min-width: 0; + gap: 10px; +} + +.rack-header-title { + color: #111827; + font-size: 22px; + font-weight: 700; + line-height: 1.2; min-width: 0; + flex: 1 1 auto; + max-width: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rack-header-actions { + display: flex !important; + align-items: center; gap: 8px; - flex: 1; + flex-shrink: 0; } -.rack-back-inline-btn { - color: #64748b !important; - height: 28px !important; - width: 28px !important; - min-width: 28px !important; +.rack-header-action-group { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.rack-header-action-separator { + width: 1px; + height: 26px; + background: #e5e7eb; +} + +.rack-header-action-btn { + width: 46px !important; + height: 46px !important; padding: 0 !important; - border-radius: 999px !important; - border: 1px solid #dbe3ee !important; + border-radius: 8px !important; + border-color: #e5e7eb !important; + color: #111827 !important; background: #fff !important; display: inline-flex !important; align-items: center; justify-content: center; } -.rack-back-inline-btn:hover { +.rack-header-action-btn :deep(.anticon) { + font-size: 18px; +} + +.rack-header-action-btn:hover { color: #1677ff !important; - border-color: #dbeafe !important; - background: #f0f7ff !important; + border-color: #1677ff !important; +} + +.rack-header-action-danger { + color: #ff4d4f !important; +} + +.rack-header-action-danger:hover { + color: #ff4d4f !important; + border-color: #ff4d4f !important; +} + +.rack-header-meta-row { + display: flex; + align-items: center; + flex-wrap: nowrap; + gap: 6px; + margin-top: 4px; + color: #6b7280; + font-size: 14px; + font-weight: 500; + min-width: 0; + overflow: hidden; +} + +.rack-header-meta-item { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; + min-width: 0; +} + +.rack-header-meta-item :deep(.anticon) { + color: #9ca3af; + font-size: 16px; +} + +.rack-header-meta-divider { + flex: 0 0 auto; + color: #9ca3af; + font-weight: 600; + line-height: 1; +} + +.rack-header-meta-location { + flex: 1 1 auto; + overflow: hidden; +} + +.rack-header-meta-ellipsis { + display: inline-block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: bottom; } /* 랙 본체 가로 정렬용 */ @@ -2915,27 +3137,6 @@ onBeforeUnmount(() => { pointer-events: none; } -/* 랙 이름 텍스트: 길어지면 말줄임표 처리 */ -.rack-name-text { - flex: 1; - min-width: 0; /* flex 환경에서 ellipsis 작동 필수 조건 */ - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - font-size: 19px; /* 가독성과 공간 확보를 위한 최적 크기 */ - font-weight: bold; - margin-right: 10px; - /* 마우스 오버 시 클릭 가능한 느낌 전달 */ - //cursor: pointer; - transition: color 0.2s ease; -} - -/* 마우스 호버 시 텍스트 색상을 밝게 변경 */ -.rack-name-text:hover { - color: #1677ff !important; - text-decoration: none; /* 밑줄 추가로 가독성 확보 */ -} - .rack-item:last-child { border-bottom: none; margin-bottom: 0; @@ -2945,41 +3146,6 @@ onBeforeUnmount(() => { content: none; } -/* 1. 버튼들을 감싸는 컨테이너를 무조건 가로(Row)로 배치 */ -.rack-header-actions { - display: flex !important; - flex-direction: row !important; /* 세로로 쌓이는 것 방지 */ - align-items: center; - flex-shrink: 0; -} - -/* 2. 개별 버튼 스타일 (inline-flex를 써야 가로 배열이 유지됨) */ -.rack-header-actions .ant-btn { - color: #334155 !important; - padding: 0 8px !important; - height: 34px !important; - min-width: 34px; - display: inline-flex !important; /* flex 대신 inline-flex 적용 */ - align-items: center; - justify-content: center; - background: transparent; - border: 1px solid #d8e1ec; - border-radius: 6px; - box-shadow: none; -} - -/* 3. 내부 Ant Design 아이콘 색상 강제 (검은색 변함 방지) */ -.rack-header-actions .ant-btn :deep(.anticon) { - color: #334155 !important; - font-size: 18px !important; -} - -/* 4. 버튼 비활성화 상태 색상 처리 */ -.rack-header-actions .ant-btn:disabled, -.rack-header-actions .ant-btn:disabled :deep(.anticon) { - color: rgba(51, 65, 85, 0.35) !important; -} - /* 여백 스타일 */ .gap-content { height: 100%; @@ -3432,7 +3598,11 @@ onBeforeUnmount(() => { color: rgba(255, 255, 255, 0.9); } -.rack-diagram-root.is-dark .rack-list-card-sub, +.rack-diagram-root.is-dark .rack-list-card-rack-icon { + color: rgba(255, 255, 255, 0.9); +} + +.rack-diagram-root.is-dark .rack-list-card-meta, .rack-diagram-root.is-dark .rack-list-card-usage-detail, .rack-diagram-root.is-dark .rack-list-card-extra { color: rgba(255, 255, 255, 0.65); @@ -3442,18 +3612,6 @@ onBeforeUnmount(() => { color: rgba(255, 255, 255, 0.9); } -.rack-diagram-root.is-dark .rack-back-inline-btn { - color: rgba(255, 255, 255, 0.72) !important; - border-color: #3a4654 !important; - background: #1f2732 !important; -} - -.rack-diagram-root.is-dark .rack-back-inline-btn:hover { - color: #91caff !important; - border-color: #31445f !important; - background: #273244 !important; -} - .rack-diagram-root.is-dark .device-info-row { color: rgba(255, 255, 255, 0.78); } diff --git a/ui/src/views/infra/zone/components/RackListCardIcon.vue b/ui/src/views/infra/zone/components/RackListCardIcon.vue new file mode 100644 index 000000000000..5294f30e4d5b --- /dev/null +++ b/ui/src/views/infra/zone/components/RackListCardIcon.vue @@ -0,0 +1,74 @@ + + + From 82ad01351e122f3f206b47eca82888a1f6fdf321 Mon Sep 17 00:00:00 2001 From: stardom3645 Date: Thu, 28 May 2026 10:19:28 +0900 Subject: [PATCH 12/17] =?UTF-8?q?[Mold][=EB=9E=99=20=EC=8B=9C=EA=B0=81?= =?UTF-8?q?=ED=99=94]=20=EB=9E=99=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=A7=88=EC=9A=B0=EC=8A=A4=20=EC=98=A4=EB=B2=84=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/src/views/infra/zone/RackDiagramTab.vue | 37 ++++++++++++++++++---- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/ui/src/views/infra/zone/RackDiagramTab.vue b/ui/src/views/infra/zone/RackDiagramTab.vue index 31d66d49694e..06c284103937 100644 --- a/ui/src/views/infra/zone/RackDiagramTab.vue +++ b/ui/src/views/infra/zone/RackDiagramTab.vue @@ -112,7 +112,13 @@
-
{{ rack.name }}
+ +
{{ rack.name }}
+
{{ t('label.created') }} {{ getRackCreatedDate(rack) }}
@@ -151,11 +157,15 @@
- - - + + {{ rack.name }} ({{ rack.totalHeight }}U) @@ -1315,6 +1325,21 @@ const formatRackDate = (value) => { } const getRackCreatedDate = (rack) => formatRackDate(rack?.createdAt || rack?.created || rack?.createDate) const getRackLocation = (rack) => rack?.location || rack?.room || rack?.position || '-' +const overflowTitleState = reactive({}) +const overflowTitleRefs = new Map() + +const setOverflowTitleRef = (key) => (el) => { + if (el) overflowTitleRefs.set(key, el) + else overflowTitleRefs.delete(key) +} + +const updateOverflowTitle = (key) => { + const el = overflowTitleRefs.get(key) + overflowTitleState[key] = !!el && (el.scrollWidth > el.clientWidth + 1) +} + +const getOverflowTitle = (key, fullText) => (overflowTitleState[key] ? fullText : null) + const searchQuery = ref('') const hasSearchQuery = computed(() => !!searchQuery.value?.trim()) From 1954e447c559cc8c0129202a14f6d9c6d1890f86 Mon Sep 17 00:00:00 2001 From: stardom3645 Date: Mon, 8 Jun 2026 16:42:11 +0900 Subject: [PATCH 13/17] =?UTF-8?q?[Mold][=EB=9E=99=20=EC=8B=9C=EA=B0=81?= =?UTF-8?q?=ED=99=94]=202=EC=B0=A8=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/cloud/rack/RackLayoutVO.java | 1 + ui/public/locales/en.json | 97 +- ui/public/locales/ko_KR.json | 95 + ui/src/views/infra/InfraSummary.vue | 86 +- ui/src/views/infra/zone/RackDiagramTab.vue | 3015 +++++++++++++++-- .../zone/components/RackListCardIcon.vue | 100 +- 6 files changed, 3030 insertions(+), 364 deletions(-) diff --git a/plugins/integrations/rack-management/src/main/java/com/cloud/rack/RackLayoutVO.java b/plugins/integrations/rack-management/src/main/java/com/cloud/rack/RackLayoutVO.java index 9f9cfb288727..aaa26fc831ae 100644 --- a/plugins/integrations/rack-management/src/main/java/com/cloud/rack/RackLayoutVO.java +++ b/plugins/integrations/rack-management/src/main/java/com/cloud/rack/RackLayoutVO.java @@ -41,6 +41,7 @@ public RackLayoutVO(Long zoneId, String name, String content) { this.zoneId = zoneId; this.name = name; this.content = content; + this.updatedAt = new Date(); } @Override diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index d6420291ba70..e4b596ddc6e8 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -5114,19 +5114,105 @@ "rackDiagram.deviceTypeKvm": "KVM / Console", "rackDiagram.deviceTypeCooling": "Cooling Fan Unit", "rackDiagram.deviceTypePatch": "Patch Panel", +"rackDiagram.deviceTypePdu": "PDU", +"rackDiagram.deviceTypeUps": "UPS", "rackDiagram.deviceTypeBlank": "Blank Panel", "rackDiagram.deviceTypeCustom": "Custom", "rackDiagram.deviceName": "Device name", "rackDiagram.deviceNamePlaceholder": "Enter device name.", "rackDiagram.selectInfraAsset": "Select infra asset", +"rackDiagram.linkedInfraAsset": "Linked infra asset", +"rackDiagram.infraAsset": "Infra asset", "rackDiagram.selectHost": "Select host.", "rackDiagram.customTypeName": "Custom type name", "rackDiagram.customTypePlaceholder": "e.g. Router", "rackDiagram.height": "Height (U)", "rackDiagram.maxAllowedHeightPrefix": "Maximum allowed height at this position is", "rackDiagram.maxAllowedHeightSuffix": ".", -"rackDiagram.deviceMemo": "Device memo / details", +"rackDiagram.deviceMemo": "Device memo", +"rackDiagram.hardwareInfo": "Hardware information", +"rackDiagram.vendor": "Vendor", +"rackDiagram.model": "Model", +"rackDiagram.serial": "Serial", +"rackDiagram.assetNo": "Asset No.", +"rackDiagram.purchaseDate": "Purchase date", +"rackDiagram.maintenanceEndDate": "Maintenance end date", +"rackDiagram.vendorPlaceholder": "e.g. Dell", +"rackDiagram.modelPlaceholder": "e.g. PowerEdge R750", +"rackDiagram.serialPlaceholder": "e.g. ABCD1234", +"rackDiagram.assetNoPlaceholder": "e.g. ASSET-001", +"rackDiagram.deviceSpecs": "Device specs", +"rackDiagram.deviceSpecKeyPlaceholder": "e.g. CPU", +"rackDiagram.deviceSpecValuePlaceholder": "e.g. 32 cores", +"rackDiagram.specCpuCores": "CPU Cores", +"rackDiagram.specCpuMhz": "CPU (MHz)", +"rackDiagram.specMemoryMb": "Memory (MB)", +"rackDiagram.specDiskGb": "Disk (GB)", +"rackDiagram.specStorageTb": "Storage (TB)", +"rackDiagram.specNicCount": "NIC Count", +"rackDiagram.specNetworkPortCount": "Network Ports", +"rackDiagram.specThroughputGbps": "Throughput (Gbps)", +"rackDiagram.specPowerW": "Power (W)", +"rackDiagram.specVoltageV": "Voltage (V)", +"rackDiagram.specCurrentA": "Current (A)", +"rackDiagram.specCapacityVa": "Capacity (VA)", +"rackDiagram.specBatteryRuntimeMin": "Battery Runtime (min)", +"rackDiagram.specAirflowCfm": "Airflow (CFM)", +"rackDiagram.specFanRpm": "Fan Speed (RPM)", +"rackDiagram.specFirmware": "Firmware", +"rackDiagram.specOs": "OS", +"rackDiagram.specHypervisor": "Hypervisor", +"rackDiagram.specCpuGhz": "CPU (GHz)", +"rackDiagram.specCpuSocketCount": "CPU Sockets", +"rackDiagram.specGpuCount": "GPU Count", +"rackDiagram.specGpuMemoryGib": "GPU Memory (GiB)", +"rackDiagram.specMemoryGb": "Memory (GB)", +"rackDiagram.specMemoryGib": "Memory (GiB)", +"rackDiagram.specMemoryTb": "Memory (TB)", +"rackDiagram.specMemoryTib": "Memory (TiB)", +"rackDiagram.specDiskGib": "Disk (GiB)", +"rackDiagram.specDiskTb": "Disk (TB)", +"rackDiagram.specDiskTib": "Disk (TiB)", +"rackDiagram.specStorageGb": "Storage (GB)", +"rackDiagram.specStorageGib": "Storage (GiB)", +"rackDiagram.specStorageTib": "Storage (TiB)", +"rackDiagram.specCapacityGb": "Capacity (GB)", +"rackDiagram.specCapacityGib": "Capacity (GiB)", +"rackDiagram.specCapacityTb": "Capacity (TB)", +"rackDiagram.specCapacityTib": "Capacity (TiB)", +"rackDiagram.specDriveBayCount": "Drive Bays", +"rackDiagram.specPortCount": "Port Count", +"rackDiagram.specThroughputMbps": "Throughput (Mbps)", +"rackDiagram.specThroughputTbps": "Throughput (Tbps)", +"rackDiagram.specPowerKw": "Power (kW)", +"rackDiagram.specCapacityKva": "Capacity (kVA)", +"rackDiagram.specBatteryCapacityWh": "Battery Capacity (Wh)", +"rackDiagram.specFanCount": "Fan Count", +"rackDiagram.addSpecRow": "Add spec row", "rackDiagram.deviceMemoPlaceholder": "Enter IP, purpose, owner, etc.", +"rackDiagram.tabSummary": "Summary", +"rackDiagram.tabConnections": "Connections", +"rackDiagram.tabHistory": "History", +"rackDiagram.sectionBasicInfo": "Basic information", +"rackDiagram.position": "Position", +"rackDiagram.structuredMemo": "Device details", +"rackDiagram.freeMemo": "Free memo", +"rackDiagram.memoKey": "Item", +"rackDiagram.memoValue": "Content", +"rackDiagram.unit": "Unit", +"rackDiagram.addMemoRow": "Add row", +"rackDiagram.selectedDeviceActions": "Selected device actions", +"rackDiagram.revert": "Revert", +"rackDiagram.changeInfraAsset": "Change infra asset", +"rackDiagram.changeInfraAssetHelp": "Changing the linked infra asset only updates the rack device mapping. Save the device panel to apply the change.", +"rackDiagram.changeInfraAssetInlineHelp": "Changes the infra asset linked to this device.", +"rackDiagram.deviceDelete": "Delete device", +"rackDiagram.noQuickLinks": "No shortcut links registered.", +"rackDiagram.links": "Links", +"rackDiagram.hostLinks": "Host links", +"rackDiagram.hostLabel": "Host", +"rackDiagram.customLinks": "Custom links", +"rackDiagram.noHistory": "No history to display.", "rackDiagram.quickLinks": "Custom shortcut links (one per line)", "rackDiagram.quickLinksHelpText": "Format: LinkName: URL (e.g. iDRAC: https://10.10.12.2:9090, Cube: 10.10.12.3:9090)", "rackDiagram.quickLinksPlaceholder": "Examples:\niDRAC: https://10.10.12.2:9090\nCube: 10.10.12.3:9090\nNAS|https://nas.local\n(If protocol is omitted, https:// is added automatically)", @@ -5190,6 +5276,7 @@ "rackDiagram.msg.enterCustomType": "Please enter custom type name.", "rackDiagram.msg.customTypeMax": "Custom type name must be 60 characters or less.", "rackDiagram.msg.deviceHeightInteger": "Device height must be an integer of 1 or more.", +"rackDiagram.msg.hardwareFieldMax": "{field} must be 128 characters or less.", "rackDiagram.msg.deviceLargerThanGap": "Cannot add a device larger than selected gap.", "rackDiagram.msg.notEnoughGapBelow": "Not enough gap below to increase size.", "rackDiagram.msg.notEnoughSpaceInRack": "Not enough free space in rack. ({height}U needed)", @@ -5207,7 +5294,15 @@ "rackDiagram.total": "total", "rackDiagram.free": "Free", "rackDiagram.searchMatched": "Matches {count}", +"rackDiagram.moreIpCount": "+ {count} more", "rackDiagram.freeSpace": "Free Space", "rackDiagram.deviceCount": "Devices", +"rackDiagram.rackStatus": "Rack Status", +"rackDiagram.rackStatusDesc": "Check the usage status of all racks at a glance.", +"rackDiagram.totalRackCount": "Total Racks", +"rackDiagram.totalDeviceCount": "Total Devices", +"rackDiagram.averageUsage": "Average Usage", +"rackDiagram.totalUsedU": "Total Used U", +"rackDiagram.totalFreeU": "Free U", "rackDiagram.countSuffix": "" } diff --git a/ui/public/locales/ko_KR.json b/ui/public/locales/ko_KR.json index 887198863e02..f1b3295aad5d 100644 --- a/ui/public/locales/ko_KR.json +++ b/ui/public/locales/ko_KR.json @@ -5116,11 +5116,15 @@ "rackDiagram.deviceTypeKvm": "KVM / \ucf58\uc194", "rackDiagram.deviceTypeCooling": "\ucfe8\ub9c1 \ud32c \uc720\ub2db (Cooling Fan Unit)", "rackDiagram.deviceTypePatch": "\ud328\uce58\ud328\ub110 (Patch Panel)", +"rackDiagram.deviceTypePdu": "PDU", +"rackDiagram.deviceTypeUps": "UPS", "rackDiagram.deviceTypeBlank": "\ube14\ub7ad\ud06c \ud328\ub110 (Blank)", "rackDiagram.deviceTypeCustom": "\ucee4\uc2a4\ud140 (Custom)", "rackDiagram.deviceName": "\uc7a5\ube44\uba85", "rackDiagram.deviceNamePlaceholder": "\uc7a5\ube44\uba85\uc744 \uc785\ub825\ud558\uc138\uc694.", "rackDiagram.selectInfraAsset": "\uc778\ud504\ub77c \uc790\uc0b0 \uc120\ud0dd", +"rackDiagram.linkedInfraAsset": "\uc5f0\uacb0 \uc778\ud504\ub77c \uc790\uc0b0", +"rackDiagram.infraAsset": "\uc778\ud504\ub77c \uc790\uc0b0", "rackDiagram.selectHost": "Host\ub97c \uc120\ud0dd\ud558\uc138\uc694.", "rackDiagram.customTypeName": "\ucee4\uc2a4\ud140 \ud0c0\uc785\uba85", "rackDiagram.customTypePlaceholder": "\uc608: Router", @@ -5128,7 +5132,89 @@ "rackDiagram.maxAllowedHeightPrefix": "\ud604\uc7ac \uc774 \uc704\uce58\uc758 \ucd5c\ub300 \ud5c8\uc6a9 \ub192\uc774\ub294", "rackDiagram.maxAllowedHeightSuffix": "\uc785\ub2c8\ub2e4.", "rackDiagram.deviceMemo": "\uc7a5\ube44 \uba54\ubaa8", +"rackDiagram.hardwareInfo": "\ud558\ub4dc\uc6e8\uc5b4 \uc815\ubcf4", +"rackDiagram.vendor": "\ubca4\ub354", +"rackDiagram.model": "\ubaa8\ub378", +"rackDiagram.serial": "\uc2dc\ub9ac\uc5bc", +"rackDiagram.assetNo": "\uc790\uc0b0\ubc88\ud638", +"rackDiagram.purchaseDate": "\uad6c\ub9e4\uc77c", +"rackDiagram.maintenanceEndDate": "\uc720\uc9c0\ubcf4\uc218 \ub9cc\ub8cc\uc77c", +"rackDiagram.vendorPlaceholder": "\uc608: Dell", +"rackDiagram.modelPlaceholder": "\uc608: PowerEdge R750", +"rackDiagram.serialPlaceholder": "\uc608: ABCD1234", +"rackDiagram.assetNoPlaceholder": "\uc608: ASSET-001", +"rackDiagram.deviceSpecs": "\uc7a5\ube44 \uc2a4\ud399", +"rackDiagram.deviceSpecKeyPlaceholder": "\uc608: CPU", +"rackDiagram.deviceSpecValuePlaceholder": "\uc608: 32\ucf54\uc5b4", +"rackDiagram.specCpuCores": "CPU \ucf54\uc5b4", +"rackDiagram.specCpuMhz": "CPU(MHz)", +"rackDiagram.specMemoryMb": "\uba54\ubaa8\ub9ac(MB)", +"rackDiagram.specDiskGb": "\ub514\uc2a4\ud06c(GB)", +"rackDiagram.specStorageTb": "\uc2a4\ud1a0\ub9ac\uc9c0(TB)", +"rackDiagram.specNicCount": "NIC \uc218\ub7c9", +"rackDiagram.specNetworkPortCount": "\ub124\ud2b8\uc6cc\ud06c \ud3ec\ud2b8 \uc218", +"rackDiagram.specThroughputGbps": "\ucc98\ub9ac\ub7c9(Gbps)", +"rackDiagram.specPowerW": "\uc804\ub825(W)", +"rackDiagram.specVoltageV": "\uc804\uc555(V)", +"rackDiagram.specCurrentA": "\uc804\ub958(A)", +"rackDiagram.specCapacityVa": "\uc6a9\ub7c9(VA)", +"rackDiagram.specBatteryRuntimeMin": "\ubc30\ud130\ub9ac \uc9c0\uc18d\uc2dc\uac04(\ubd84)", +"rackDiagram.specAirflowCfm": "\ud48d\ub7c9(CFM)", +"rackDiagram.specFanRpm": "\ud32c \uc18d\ub3c4(RPM)", +"rackDiagram.specFirmware": "\ud38c\uc6e8\uc5b4", +"rackDiagram.specOs": "OS", +"rackDiagram.specHypervisor": "\ud558\uc774\ud37c\ubc14\uc774\uc800", +"rackDiagram.specCpuGhz": "CPU(GHz)", +"rackDiagram.specCpuSocketCount": "CPU \uc18c\ucf13 \uc218", +"rackDiagram.specGpuCount": "GPU \uc218\ub7c9", +"rackDiagram.specGpuMemoryGib": "GPU \uba54\ubaa8\ub9ac(GiB)", +"rackDiagram.specMemoryGb": "\uba54\ubaa8\ub9ac(GB)", +"rackDiagram.specMemoryGib": "\uba54\ubaa8\ub9ac(GiB)", +"rackDiagram.specMemoryTb": "\uba54\ubaa8\ub9ac(TB)", +"rackDiagram.specMemoryTib": "\uba54\ubaa8\ub9ac(TiB)", +"rackDiagram.specDiskGib": "\ub514\uc2a4\ud06c(GiB)", +"rackDiagram.specDiskTb": "\ub514\uc2a4\ud06c(TB)", +"rackDiagram.specDiskTib": "\ub514\uc2a4\ud06c(TiB)", +"rackDiagram.specStorageGb": "\uc2a4\ud1a0\ub9ac\uc9c0(GB)", +"rackDiagram.specStorageGib": "\uc2a4\ud1a0\ub9ac\uc9c0(GiB)", +"rackDiagram.specStorageTib": "\uc2a4\ud1a0\ub9ac\uc9c0(TiB)", +"rackDiagram.specCapacityGb": "\uc6a9\ub7c9(GB)", +"rackDiagram.specCapacityGib": "\uc6a9\ub7c9(GiB)", +"rackDiagram.specCapacityTb": "\uc6a9\ub7c9(TB)", +"rackDiagram.specCapacityTib": "\uc6a9\ub7c9(TiB)", +"rackDiagram.specDriveBayCount": "\ub4dc\ub77c\uc774\ube0c \ubca0\uc774 \uc218", +"rackDiagram.specPortCount": "\ud3ec\ud2b8 \uc218", +"rackDiagram.specThroughputMbps": "\ucc98\ub9ac\ub7c9(Mbps)", +"rackDiagram.specThroughputTbps": "\ucc98\ub9ac\ub7c9(Tbps)", +"rackDiagram.specPowerKw": "\uc804\ub825(kW)", +"rackDiagram.specCapacityKva": "\uc6a9\ub7c9(kVA)", +"rackDiagram.specBatteryCapacityWh": "\ubc30\ud130\ub9ac \uc6a9\ub7c9(Wh)", +"rackDiagram.specFanCount": "\ud32c \uc218\ub7c9", +"rackDiagram.addSpecRow": "\uc2a4\ud399 \ud589 \ucd94\uac00", "rackDiagram.deviceMemoPlaceholder": "IP \uc8fc\uc18c, \uc6a9\ub3c4, \ub2f4\ub2f9\uc790 \ub4f1\uc744 \uc785\ub825\ud558\uc138\uc694", +"rackDiagram.tabSummary": "\uc694\uc57d", +"rackDiagram.tabConnections": "\uc5f0\uacb0", +"rackDiagram.tabHistory": "\uc774\ub825", +"rackDiagram.sectionBasicInfo": "\uae30\ubcf8 \uc815\ubcf4", +"rackDiagram.position": "\uc704\uce58", +"rackDiagram.structuredMemo": "\uc7a5\ube44 \uc138\ubd80\uc0ac\ud56d", +"rackDiagram.freeMemo": "\uc790\uc720 \uba54\ubaa8", +"rackDiagram.memoKey": "\ud56d\ubaa9", +"rackDiagram.memoValue": "\ub0b4\uc6a9", +"rackDiagram.unit": "\ub2e8\uc704", +"rackDiagram.addMemoRow": "\ud589 \ucd94\uac00", +"rackDiagram.selectedDeviceActions": "\uc120\ud0dd \uc7a5\ube44 \uc791\uc5c5", +"rackDiagram.revert": "\ub418\ub3cc\ub9ac\uae30", +"rackDiagram.changeInfraAsset": "\uc790\uc0b0 \uc5f0\uacb0 \ubcc0\uacbd", +"rackDiagram.changeInfraAssetHelp": "\uc5f0\uacb0\ub41c \uc778\ud504\ub77c \uc790\uc0b0\ub9cc \ubcc0\uacbd\ud569\ub2c8\ub2e4. \ubcc0\uacbd\uc0ac\ud56d\uc744 \uc801\uc6a9\ud558\ub824\uba74 \uc6b0\uce21 \ud328\ub110\uc5d0\uc11c \uc800\uc7a5\ud558\uc138\uc694.", +"rackDiagram.changeInfraAssetInlineHelp": "\uc774 \uc7a5\ube44\uac00 \uc5f0\uacb0\ub41c \uc778\ud504\ub77c \uc790\uc0b0\uc744 \ubcc0\uacbd\ud569\ub2c8\ub2e4.", +"rackDiagram.deviceDelete": "\uc7a5\ube44 \uc0ad\uc81c", +"rackDiagram.noQuickLinks": "\ub4f1\ub85d\ub41c \ubc14\ub85c\uac00\uae30 \ub9c1\ud06c\uac00 \uc5c6\uc2b5\ub2c8\ub2e4.", +"rackDiagram.links": "\ub9c1\ud06c", +"rackDiagram.hostLinks": "\ud638\uc2a4\ud2b8 \ub9c1\ud06c", +"rackDiagram.hostLabel": "\ud638\uc2a4\ud2b8", +"rackDiagram.customLinks": "\ucee4\uc2a4\ud140 \ub9c1\ud06c", +"rackDiagram.noHistory": "\ud45c\uc2dc\ud560 \uc774\ub825\uc774 \uc5c6\uc2b5\ub2c8\ub2e4.", "rackDiagram.quickLinks": "\ucee4\uc2a4\ud140 \ubc14\ub85c\uac00\uae30 \ub9c1\ud06c", "rackDiagram.quickLinksHelpText": "\uc608: iDRAC: https://10.10.10.10:9090, Cube: 10.10.10.10:9090", "rackDiagram.quickLinksPlaceholder": "\uc608\uc2dc:\niDRAC: https://10.10.10.10:9090\nCube: 10.10.10.10:9090\nNAS|https://nas.local\n(\ud504\ub85c\ud1a0\ucf5c\uc744 \uc0dd\ub7b5\ud558\uba74 https://\uac00 \uc790\ub3d9 \ucd94\uac00\ub429\ub2c8\ub2e4)", @@ -5192,6 +5278,7 @@ "rackDiagram.msg.enterCustomType": "\ucee4\uc2a4\ud140 \ud0c0\uc785\uba85\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694.", "rackDiagram.msg.customTypeMax": "\ucee4\uc2a4\ud140 \ud0c0\uc785\uba85\uc740 60\uc790 \uc774\ud558\ub85c \uc785\ub825\ud574\uc8fc\uc138\uc694.", "rackDiagram.msg.deviceHeightInteger": "\uc7a5\ube44 \ub192\uc774(U)\ub294 1 \uc774\uc0c1\uc758 \uc815\uc218\ub9cc \uc785\ub825\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.", +"rackDiagram.msg.hardwareFieldMax": "{field}\uc740(\ub294) 128\uc790 \uc774\ud558\ub85c \uc785\ub825\ud574\uc8fc\uc138\uc694.", "rackDiagram.msg.deviceLargerThanGap": "\uc120\ud0dd\ud55c \uc5ec\ubc31\ubcf4\ub2e4 \ud070 \uc7a5\ube44\ub294 \ucd94\uac00\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.", "rackDiagram.msg.notEnoughGapBelow": "\uc544\ub798\ucabd \uc5ec\ubc31\uc774 \ubd80\uc871\ud558\uc5ec \ud06c\uae30\ub97c \ub298\ub9b4 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.", "rackDiagram.msg.notEnoughSpaceInRack": "\ub799 \ub0b4\uc5d0 \uc5ec\uc720 \uacf5\uac04\uc774 \ubd80\uc871\ud569\ub2c8\ub2e4. (\ud544\uc694 \uacf5\uac04: {height}U)", @@ -5209,7 +5296,15 @@ "rackDiagram.total": "\uc804\uccb4", "rackDiagram.free": "\uc5ec\uc720", "rackDiagram.searchMatched": "\uac80\uc0c9 \uc77c\uce58 {count}", +"rackDiagram.moreIpCount": "\uc678 {count}\uac1c", "rackDiagram.freeSpace": "\uc5ec\uc720 \uacf5\uac04", "rackDiagram.deviceCount": "\uc7a5\ube44", +"rackDiagram.rackStatus": "\ub799 \ud604\ud669", +"rackDiagram.rackStatusDesc": "\uc804\uccb4 \ub799\uc758 \uc0ac\uc6a9 \ud604\ud669\uc744 \ud55c\ub208\uc5d0 \ud655\uc778\ud569\ub2c8\ub2e4.", +"rackDiagram.totalRackCount": "\ucd1d \ub799 \uc218", +"rackDiagram.totalDeviceCount": "\ucd1d \uc7a5\ube44 \uc218", +"rackDiagram.averageUsage": "\ud3c9\uade0 \uc0ac\uc6a9\ub960", +"rackDiagram.totalUsedU": "\ucd1d \uc0ac\uc6a9 U", +"rackDiagram.totalFreeU": "\uc5ec\uc720 U", "rackDiagram.countSuffix": "\uac1c" } diff --git a/ui/src/views/infra/InfraSummary.vue b/ui/src/views/infra/InfraSummary.vue index 8f62a039d70a..8c92941f7f45 100644 --- a/ui/src/views/infra/InfraSummary.vue +++ b/ui/src/views/infra/InfraSummary.vue @@ -150,14 +150,23 @@
- - - + +
@@ -204,6 +213,13 @@ export default { loading: true, routes: {}, sections: ['zones', 'pods', 'clusters', 'hosts', 'storagepools', 'imagestores', 'objectstores', 'systemvms', 'routers', 'cpusockets', 'managementservers', 'alerts', 'ilbvms', 'metrics'], + summaryGroups: [ + { key: 'configuration', titleParts: ['label.infrastructure', 'label.configuration'], items: ['zones', 'pods', 'clusters'] }, + { key: 'compute', title: 'label.compute', items: ['hosts', 'cpusockets', 'systemvms', 'metrics'] }, + { key: 'network', title: 'label.network', items: ['routers', 'ilbvms'] }, + { key: 'storage', title: 'label.storage', items: ['storagepools', 'imagestores', 'objectstores'] }, + { key: 'incident', title: 'label.alerts', items: ['alerts'] } + ], sslFormVisible: false, stats: {}, intermediateCertificates: [], @@ -218,7 +234,23 @@ export default { this.initForm() this.fetchData() }, + computed: { + visibleSummaryGroups () { + return this.summaryGroups + .map(group => { + const items = group.items.filter(section => this.routes[section]) + return { ...group, items } + }) + .filter(group => group.items.length) + } + }, methods: { + getSummaryGroupTitle (group) { + if (group.titleParts) { + return group.titleParts.map(key => this.$t(key)).join(' ') + } + return this.$t(group.title) + }, initForm () { this.formRef = ref() this.form = reactive({}) @@ -362,11 +394,6 @@ export default { margin-bottom: 12px; } - .chart-card-inner { - text-align: center; - white-space: nowrap; - overflow: hidden; - } .infra-summary-layout { display: flex; align-items: flex-start; @@ -381,11 +408,50 @@ export default { background: linear-gradient(180deg, #f8f9fb 0%, #f3f5f8 100%); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65); } + .summary-group { + padding: 10px 10px 0; + border: 1px solid rgba(31, 41, 55, 0.08); + border-radius: 10px; + background: rgba(255, 255, 255, 0.54); + } + .summary-group + .summary-group { + margin-top: 12px; + } + .summary-group-header { + display: flex; + align-items: center; + margin: -2px 2px 10px; + padding-bottom: 8px; + border-bottom: 1px solid rgba(31, 41, 55, 0.06); + color: #111827; + font-weight: 700; + font-size: 13px; + line-height: 18px; + } + .summary-group-title { + position: relative; + padding-left: 9px; + } + .summary-group-title::before { + content: ''; + position: absolute; + left: 0; + top: 4px; + width: 3px; + height: 11px; + border-radius: 2px; + background: #c7d2df; + } .rack-visualization-pane { width: 66%; flex: 0 0 66%; min-width: 320px; } + .chart-card-inner { + text-align: center; + white-space: nowrap; + overflow: hidden; + } .summary-mini-card :deep(.ant-card-body) { padding: 12px 14px 6px !important; } diff --git a/ui/src/views/infra/zone/RackDiagramTab.vue b/ui/src/views/infra/zone/RackDiagramTab.vue index 06c284103937..de32805810ad 100644 --- a/ui/src/views/infra/zone/RackDiagramTab.vue +++ b/ui/src/views/infra/zone/RackDiagramTab.vue @@ -14,28 +14,57 @@
-
+
- - {{ t('rackDiagram.addRack') }} - {{ t('rackDiagram.save') }} - - {{ t('rackDiagram.backup') }} - - - {{ t('rackDiagram.restore') }} - - - {{ t('rackDiagram.capture') }} - + + + + + {{ t('label.actions') }} + +
-
- {{ t('rackDiagram.zoom') }}: +
+ {{ t('rackDiagram.zoom') }}: -
+
-
+
@@ -191,7 +285,7 @@ - + @@ -229,6 +323,7 @@ v-for="u in rack.totalHeight" :key="u" class="ruler-number" + :class="{ 'ruler-number-selected': isSelectedRulerUnit(rack, rIndex, u) }" > {{ rack.totalHeight - u + 1 }}
@@ -239,6 +334,7 @@ v-for="(item, iIndex) in rack.items" :key="iIndex" class="rack-item" + :class="{ 'rack-item-selected': isSelectedItem(rIndex, iIndex), 'rack-item-hoverable': item.type !== 'gap' }" @dragover.prevent :data-item-type="item.type" :style="{ @@ -254,7 +350,7 @@
- + @@ -325,7 +421,7 @@ - + @@ -364,36 +460,300 @@ - - {{ selectedDevice.label || '-' }} - {{ selectedDevice.type || '-' }} - {{ selectedDevice.height }}U - -
-
{{ selectedDeviceMemoPlain.join('\n') }}
-
-
-
{{ line }}
+
+
+
+
+ {{ selectedDeviceDraft.label || '-' }} + {{ selectedDeviceDraft.height }}U +
+
+ {{ selectedDeviceTypeLabel }} + · {{ t('label.ip') }} {{ selectedDeviceIp }} + · + + {{ selectedDeviceStatusLabel }} + +
+
+
+ + + +
+
+
{{ t('rackDiagram.sectionBasicInfo') }}
+ + + +
+
+
+ +
+ {{ selectedDeviceDraft.label || '-' }} + +
+
+
+ +
+ {{ selectedDeviceDraft.height }}U + +
+
+
+ +
+ {{ draftPositionLabel }} + +
+
+
+
+
+
+
{{ t('rackDiagram.hardwareInfo') }}
+ + + +
+
+
+ +
+ {{ selectedDeviceDraft.hardwareInfo[field.key] || '-' }} + + + +
+
+
+
+
+
+
{{ t('rackDiagram.deviceSpecs') }}
+ + + +
+
+
+
+
{{ t('rackDiagram.memoKey') }}
+
{{ t('rackDiagram.memoValue') }}
+
{{ t('rackDiagram.quickLinkAction') }}
+
+
+ + +
+
+ +
+
+ + {{ t('rackDiagram.addSpecRow') }} + +
+
+
+
+
{{ t('rackDiagram.deviceMemo') }}
+ + + +
+
+ {{ selectedDeviceDraft.memo || '-' }} +
+ +
+
+ +
+
+
{{ t('rackDiagram.infraAsset') }}
+
+
+
+
{{ t('rackDiagram.hostLabel') }}
+
+ + {{ selectedDeviceLinkedAssetName || '-' }} + + + + + + +
+
+
+
{{ t('label.ip') }}
+
+ {{ selectedDeviceLinkedAssetIp || '-' }} + + + + + +
+
+
+
{{ row.label }}
+
{{ row.value }}
+
+
{{ t('rackDiagram.changeInfraAssetInlineHelp') }}
+ + {{ t('rackDiagram.changeInfraAsset') }} + +
- - - - {{ selectedDeviceSourceRefLabel }} - -
-
- {{ row.label }}: {{ row.value }} +
+
{{ t('rackDiagram.links') }}
+
- - + + +
- {{ t('rackDiagram.settings') }} - - {{ t('rackDiagram.delete') }} + + {{ t('rackDiagram.deviceConfig') }} + + + + {{ t('rackDiagram.deviceDelete') }} +
+
@@ -404,6 +764,8 @@ @@ -459,11 +823,15 @@ - + + {{ t('rackDiagram.deviceTypeServer') }} @@ -525,14 +893,126 @@
- + + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + + + + + + + + + + + +
+ + + + + {{ vm.displayname || vm.name || vm.id }}
{{ vm.state || '-' }} / {{ vm.ostypename || vm.hypervisor || '-' }}
+ +
{{ getVmPrimaryIpText(vm) }}
+
@@ -656,7 +1166,11 @@ import { UnorderedListOutlined, CalendarOutlined, EnvironmentOutlined, - CloseOutlined + CloseOutlined, + PieChartOutlined, + DatabaseOutlined, + InboxOutlined, + DownOutlined } from '@ant-design/icons-vue' // 전역 상태 @@ -664,7 +1178,8 @@ const loading = ref(false) const saving = ref(false) const zoomLevel = ref(0.65) const AUTO_ZOOM_MIN = 0.65 -const AUTO_ZOOM_MAX = 1.0 +const NORMAL_ZOOM_LEVEL = 0.96 +const AUTO_ZOOM_MAX = NORMAL_ZOOM_LEVEL const isAutoZoomEnabled = ref(true) const rackMainPaneRef = ref(null) const rackDetailLayoutRef = ref(null) @@ -686,13 +1201,13 @@ const RACK_UNIT_HEIGHT = 52 // 슬라이더 및 입력창과 연동할 퍼센트 단위 변수 const zoomPercent = computed({ - get: () => Math.round(zoomLevel.value * 100), + get: () => Math.round((zoomLevel.value / NORMAL_ZOOM_LEVEL) * 100), set: (val) => { isAutoZoomEnabled.value = false // 최소 40% ~ 최대 150% 범위 제한 if (val < 40) val = 40 if (val > 150) val = 150 - zoomLevel.value = val / 100 + zoomLevel.value = (val / 100) * NORMAL_ZOOM_LEVEL } }) const zoomPercentUi = ref(zoomPercent.value) @@ -769,33 +1284,385 @@ const selectedDevice = computed(() => { return item }) const selectedDeviceHost = ref(null) -const selectedDeviceMemoPlain = computed(() => { - const memo = String(selectedDevice.value?.memo || '') - const marker = '[LinkedAsset]' - return memo - .split('\n') - .map(line => line.trim()) - .filter(line => line && !line.startsWith(marker)) +const deviceInfoActiveTab = ref('summary') +const activeInlineField = ref('') +const selectedDeviceDraft = reactive({ + label: '', + height: 1, + startU: 1, + memo: '', + sourceRef: null, + customType: '', + hardwareInfo: { + vendor: '', + model: '', + serial: '', + assetNo: '', + purchaseDate: '', + maintenanceEndDate: '' + }, + specs: [], + quickLinks: [] }) -const selectedDeviceMemoLinked = computed(() => { - const memo = String(selectedDevice.value?.memo || '') - const marker = '[LinkedAsset]' - return memo +const selectedDeviceDraftBaseline = ref('') +const assetLinkModalVisible = ref(false) +const assetLinkDraft = ref(undefined) +const structuredMemoRows = ref([]) +const freeMemoText = ref('') +const deviceSpecRows = ref([]) +const LINKED_ASSET_MARKER = '[LinkedAsset]' +const stripLinkedAssetMemo = (memo) => String(memo || '') + .split('\n') + .filter(line => !line.trim().startsWith(LINKED_ASSET_MARKER)) + .join('\n') + .trim() + +const makeMemoRow = (key = '', value = '') => ({ + id: `${Date.now()}-${Math.random().toString(36).slice(2)}`, + key, + value +}) + +const createEmptyHardwareInfo = () => ({ + vendor: '', + model: '', + serial: '', + assetNo: '', + purchaseDate: '', + maintenanceEndDate: '' +}) + +const hardwareInfoFields = [ + { key: 'vendor', labelKey: 'rackDiagram.vendor' }, + { key: 'model', labelKey: 'rackDiagram.model' }, + { key: 'serial', labelKey: 'rackDiagram.serial' }, + { key: 'assetNo', labelKey: 'rackDiagram.assetNo' }, + { key: 'purchaseDate', labelKey: 'rackDiagram.purchaseDate', type: 'date' }, + { key: 'maintenanceEndDate', labelKey: 'rackDiagram.maintenanceEndDate', type: 'date' } +] + +const hardwareVendorOptions = [ + 'Dell Technologies', + 'Dell EMC', + 'HPE', + 'Hewlett Packard Enterprise', + 'Lenovo', + 'Cisco', + 'Supermicro', + 'IBM', + 'Oracle', + 'Huawei', + 'NetApp', + 'Hitachi Vantara', + 'Fujitsu', + 'Inspur', + 'NEC', + 'QNAP', + 'Synology', + 'APC', + 'Eaton', + 'Vertiv' +].map(value => ({ value })) + +const filterHardwareVendorOption = (input, option) => { + return String(option?.value || '').toLowerCase().includes(String(input || '').toLowerCase()) +} + +const deviceSpecKeyOptionKeys = [ + // Compute + 'rackDiagram.specCpuCores', + 'rackDiagram.specCpuMhz', + 'rackDiagram.specCpuGhz', + 'rackDiagram.specCpuSocketCount', + 'rackDiagram.specGpuCount', + 'rackDiagram.specGpuMemoryGib', + // Memory + 'rackDiagram.specMemoryMb', + 'rackDiagram.specMemoryGb', + 'rackDiagram.specMemoryGib', + 'rackDiagram.specMemoryTb', + 'rackDiagram.specMemoryTib', + // Capacity / storage + 'rackDiagram.specDiskGb', + 'rackDiagram.specDiskGib', + 'rackDiagram.specDiskTb', + 'rackDiagram.specDiskTib', + 'rackDiagram.specStorageGb', + 'rackDiagram.specStorageGib', + 'rackDiagram.specStorageTb', + 'rackDiagram.specStorageTib', + 'rackDiagram.specCapacityGb', + 'rackDiagram.specCapacityGib', + 'rackDiagram.specCapacityTb', + 'rackDiagram.specCapacityTib', + 'rackDiagram.specDriveBayCount', + // Network + 'rackDiagram.specNicCount', + 'rackDiagram.specNetworkPortCount', + 'rackDiagram.specPortCount', + 'rackDiagram.specThroughputMbps', + 'rackDiagram.specThroughputGbps', + 'rackDiagram.specThroughputTbps', + // Power / UPS + 'rackDiagram.specPowerW', + 'rackDiagram.specPowerKw', + 'rackDiagram.specVoltageV', + 'rackDiagram.specCurrentA', + 'rackDiagram.specCapacityVa', + 'rackDiagram.specCapacityKva', + 'rackDiagram.specBatteryRuntimeMin', + 'rackDiagram.specBatteryCapacityWh', + // Cooling + 'rackDiagram.specAirflowCfm', + 'rackDiagram.specFanRpm', + 'rackDiagram.specFanCount', + // System + 'rackDiagram.specFirmware', + 'rackDiagram.specOs', + 'rackDiagram.specHypervisor' +] + +const deviceSpecKeyOptions = computed(() => deviceSpecKeyOptionKeys.map(key => ({ value: t(key) }))) + +const filterDeviceSpecKeyOption = (input, option) => { + return String(option?.value || '').toLowerCase().includes(String(input || '').toLowerCase()) +} + +const normalizeHardwareInfo = (hardwareInfo) => { + const source = hardwareInfo && typeof hardwareInfo === 'object' ? hardwareInfo : {} + return { + vendor: String(source.vendor || '').trim(), + model: String(source.model || '').trim(), + serial: String(source.serial || '').trim(), + assetNo: String(source.assetNo || '').trim(), + purchaseDate: String(source.purchaseDate || '').trim(), + maintenanceEndDate: String(source.maintenanceEndDate || '').trim() + } +} + +const assignHardwareInfo = (target, source) => { + const normalized = normalizeHardwareInfo(source) + Object.keys(createEmptyHardwareInfo()).forEach(key => { + target[key] = normalized[key] || '' + }) +} + +const validateHardwareInfo = (hardwareInfo) => { + const normalized = normalizeHardwareInfo(hardwareInfo) + const lengthFields = [ + { key: 'vendor', labelKey: 'rackDiagram.vendor' }, + { key: 'model', labelKey: 'rackDiagram.model' }, + { key: 'serial', labelKey: 'rackDiagram.serial' }, + { key: 'assetNo', labelKey: 'rackDiagram.assetNo' } + ] + const invalid = lengthFields.find(field => normalized[field.key].length > 128) + if (invalid) { + message.warning(t('rackDiagram.msg.hardwareFieldMax', { field: t(invalid.labelKey) })) + return null + } + return normalized +} + +const parseDeviceMemo = (memo) => { + const rows = [] + const freeLines = [] + stripLinkedAssetMemo(memo) .split('\n') .map(line => line.trim()) - .filter(line => line.startsWith(marker)) - .map(line => line.replace(marker, '').trim()) + .filter(Boolean) + .forEach(line => { + const eqIndex = line.indexOf('=') + const colonIndex = line.indexOf(':') + const splitIndex = eqIndex > -1 ? eqIndex : colonIndex + if (splitIndex > 0) { + rows.push(makeMemoRow(line.slice(0, splitIndex).trim(), line.slice(splitIndex + 1).trim())) + } else { + freeLines.push(line) + } + }) + return { rows, freeText: freeLines.join('\n') } +} + +const normalizeSpecRows = (specs) => { + if (!Array.isArray(specs)) return [] + return specs + .map(row => makeMemoRow(String(row?.key || '').trim(), String(row?.value || '').trim())) + .filter(row => row.key || row.value) +} + +const getDeviceSpecRows = (device) => { + const specs = normalizeSpecRows(device?.specs) + if (specs.length) return specs + return parseDeviceMemo(device?.memo || '').rows +} + +const getDeviceFreeMemo = (device) => { + if (Array.isArray(device?.specs)) return stripLinkedAssetMemo(device?.memo || '') + return parseDeviceMemo(device?.memo || '').freeText +} + +const syncSideMemoFields = (device) => { + structuredMemoRows.value = getDeviceSpecRows(device) + freeMemoText.value = getDeviceFreeMemo(device) +} + +const visibleDeviceSpecRows = computed(() => { + if (activeInlineField.value === 'specs') return selectedDeviceDraft.specs + return selectedDeviceDraft.specs.filter(row => String(row.key || '').trim() || String(row.value || '').trim()) }) -const selectedDeviceSourceRefLabel = computed(() => { - const sourceRef = String(selectedDevice.value?.sourceRef || '') - if (!sourceRef) return '-' - if (!sourceRef.startsWith('host:')) return sourceRef - const hostId = sourceRef.split(':')[1] || '' - const host = selectedDeviceHost.value - if (host && String(host.id) === hostId) { - return host.name || host.hostname || hostId + +const cloneQuickLinksForEdit = (links) => { + return (Array.isArray(links) ? links : []).map(link => makeQuickLinkRow(link.label || '', link.url || '')) +} + +const getCurrentDeviceStartU = (rack, iIndex) => { + if (!rack || iIndex < 0) return 1 + const item = rack.items?.[iIndex] + if (!item) return 1 + const aboveHeight = rack.items.slice(0, iIndex).reduce((sum, current) => sum + Number(current.height || 0), 0) + const topU = Number(rack.totalHeight || 0) - aboveHeight + return Math.max(1, topU - Number(item.height || 0) + 1) +} + +const selectedRackTotalHeight = computed(() => { + const rack = parsedRacks.value[selectedDeviceRackIndex.value] + return Number(rack?.totalHeight || 1) +}) + +const sidePanelMaxAllowedHeight = computed(() => { + const rack = parsedRacks.value[selectedDeviceRackIndex.value] + const item = rack?.items?.[selectedDeviceItemIndex.value] + if (!rack || !item) return 1 + let physicalMax = Number(item.height || 1) + const nextItem = rack.items[selectedDeviceItemIndex.value + 1] + if (nextItem?.type === 'gap') physicalMax += Number(nextItem.height || 0) + return Math.max(1, physicalMax) +}) + +const getSideDraftSnapshot = () => ({ + label: String(selectedDeviceDraft.label || '').trim(), + height: Number(selectedDeviceDraft.height || 1), + startU: Number(selectedDeviceDraft.startU || 1), + memo: stripLinkedAssetMemo(selectedDeviceDraft.memo), + sourceRef: selectedDeviceDraft.sourceRef || null, + customType: String(selectedDeviceDraft.customType || '').trim(), + hardwareInfo: normalizeHardwareInfo(selectedDeviceDraft.hardwareInfo), + specs: getCleanSpecRows(selectedDeviceDraft.specs), + quickLinks: cloneQuickLinksForEdit(selectedDeviceDraft.quickLinks) + .map(link => ({ label: String(link.label || '').trim(), url: String(link.url || '').trim() })) + .filter(link => link.label || link.url) +}) + +const syncSelectedDeviceDraft = (device, resetActive = true) => { + const rack = parsedRacks.value[selectedDeviceRackIndex.value] + selectedDeviceDraft.label = device?.label || '' + selectedDeviceDraft.height = Number(device?.height || 1) + selectedDeviceDraft.startU = getCurrentDeviceStartU(rack, selectedDeviceItemIndex.value) + selectedDeviceDraft.memo = getDeviceFreeMemo(device) + selectedDeviceDraft.sourceRef = device?.sourceRef || null + selectedDeviceDraft.customType = device?.customType || '' + assignHardwareInfo(selectedDeviceDraft.hardwareInfo, device?.hardwareInfo) + selectedDeviceDraft.specs = getDeviceSpecRows(device).length ? getDeviceSpecRows(device) : [] + selectedDeviceDraft.quickLinks = cloneQuickLinksForEdit(getQuickLinks(device)) + selectedDeviceDraftBaseline.value = JSON.stringify(getSideDraftSnapshot()) + if (resetActive) activeInlineField.value = '' +} + +const sidePanelDevice = computed(() => ({ + ...(selectedDevice.value || {}), + label: selectedDeviceDraft.label, + height: selectedDeviceDraft.height, + customType: selectedDeviceDraft.customType, + sourceRef: selectedDeviceDraft.sourceRef, + hardwareInfo: selectedDeviceDraft.hardwareInfo, + specs: selectedDeviceDraft.specs, + memo: selectedDeviceDraft.memo, + quickLinks: selectedDeviceDraft.quickLinks +})) + +const selectedDeviceCustomLinks = computed(() => { + if (selectedDeviceRackIndex.value < 0 || selectedDeviceItemIndex.value < 0) return [] + const rack = parsedRacks.value[selectedDeviceRackIndex.value] + const item = rack?.items?.[selectedDeviceItemIndex.value] + const merged = [...getQuickLinks(item), ...getQuickLinks({ quickLinks: selectedDeviceDraft.quickLinks })] + const seen = new Set() + return merged.filter(link => { + const key = `${String(link.label || '').trim()}|${String(link.url || '').trim()}` + if (!link.url || seen.has(key)) return false + seen.add(key) + return true + }) +}) + +const draftPositionLabel = computed(() => { + const startU = Number(selectedDeviceDraft.startU || 1) + const height = Number(selectedDeviceDraft.height || 1) + return height > 1 ? `${startU}-${startU + height - 1}U` : `${startU}U` +}) + +const finishInlineEdit = () => { + if (!activeInlineField.value) return + applyInlineDeviceChangesToRack() + activeInlineField.value = '' +} + +const markInlineDraftChanged = () => { + // v-model already updates the draft. Keep the editor open until the user leaves the section. +} + +const activateInlineField = (field) => { + if (activeInlineField.value === field) { + finishInlineEdit() + return } - return hostId || sourceRef + if (activeInlineField.value) applyInlineDeviceChangesToRack() + activeInlineField.value = field + if (field === 'specs' && !selectedDeviceDraft.specs.length) addInlineSpecRow() + if (field === 'quickLinks' && !selectedDeviceDraft.quickLinks.length) addInlineQuickLink() +} + +const addInlineSpecRow = () => { + selectedDeviceDraft.specs.push(makeMemoRow()) + applyInlineDeviceChangesToRack() +} + +const removeInlineSpecRow = (idx) => { + selectedDeviceDraft.specs.splice(idx, 1) + if (!selectedDeviceDraft.specs.length) addInlineSpecRow() + applyInlineDeviceChangesToRack() +} + +const addInlineQuickLink = () => { + selectedDeviceDraft.quickLinks.push(makeQuickLinkRow()) + applyInlineDeviceChangesToRack() +} + +const deviceTypeLabelKeys = { + server: 'rackDiagram.deviceTypeServer', + blade: 'rackDiagram.deviceTypeBlade', + switch: 'rackDiagram.deviceTypeSwitch', + router: 'rackDiagram.deviceTypeRouter', + loadbalancer: 'rackDiagram.deviceTypeLoadBalancer', + storage: 'rackDiagram.deviceTypeStorage', + nas: 'rackDiagram.deviceTypeNas', + firewall: 'rackDiagram.deviceTypeFirewall', + monitoring: 'rackDiagram.deviceTypeMonitoring', + kvm: 'rackDiagram.deviceTypeKvm', + cooling: 'rackDiagram.deviceTypeCooling', + patch: 'rackDiagram.deviceTypePatch', + pdu: 'rackDiagram.deviceTypePdu', + ups: 'rackDiagram.deviceTypeUps', + blank: 'rackDiagram.deviceTypeBlank', + custom: 'rackDiagram.deviceTypeCustom' +} +const selectedDeviceTypeLabel = computed(() => { + const type = selectedDevice.value?.type + return type ? t(deviceTypeLabelKeys[type] || 'rackDiagram.deviceTypeCustom') : '-' +}) +const selectedDeviceIp = computed(() => selectedDeviceHost.value?.ipaddress || '') +const selectedDeviceStatusLabel = computed(() => { + const state = selectedDeviceHostInfo.value.find(row => row.label === t('label.state'))?.value + return state && state !== '-' ? state : '' }) const selectedDeviceHostInfo = computed(() => { const host = selectedDeviceHost.value @@ -832,6 +1699,86 @@ const selectedDeviceHostInfo = computed(() => { { label: t('label.hypervisor'), value: host.hypervisor || '-' } ] }) +const selectedDeviceHostInfoCompact = computed(() => { + const hiddenLabels = new Set([ + t('label.ip'), + t('label.state'), + t('label.resourcestate'), + t('label.powerstate') + ]) + return selectedDeviceHostInfo.value.filter(row => !hiddenLabels.has(row.label)) +}) +const selectedDeviceLinkedAsset = computed(() => { + const sourceRef = selectedDeviceDraft.sourceRef + if (!sourceRef) return { name: '-', ip: '', copyText: '' } + const selected = inventoryOptions.value.find(o => o.value === sourceRef) + if (selected?.meta) { + const name = selected.meta.name || selected.label || sourceRef + const ip = selected.meta.ip || '' + return { name, ip, copyText: ip ? `${name} / ${ip}` : name } + } + if (String(sourceRef).startsWith('host:')) { + const hostId = String(sourceRef).split(':')[1] || '' + const host = selectedDeviceHost.value || hostCache.value[hostId] + if (host) { + const name = host.name || host.hostname || host.id || hostId + const ip = host.ipaddress || '' + return { name, ip, copyText: ip ? `${name} / ${ip}` : name } + } + } + return { name: sourceRef, ip: '', copyText: sourceRef } +}) +const selectedDeviceLinkedAssetName = computed(() => selectedDeviceLinkedAsset.value.name) +const selectedDeviceLinkedAssetIp = computed(() => selectedDeviceLinkedAsset.value.ip) + +const copyTextToClipboard = async (text) => { + const value = String(text || '').trim() + if (!value) return + try { + await navigator.clipboard.writeText(value) + message.success(t('message.success.copy.clipboard')) + } catch (e) { + const textarea = document.createElement('textarea') + textarea.value = value + textarea.style.position = 'fixed' + textarea.style.opacity = '0' + document.body.appendChild(textarea) + textarea.select() + document.execCommand('copy') + document.body.removeChild(textarea) + message.success(t('message.success.copy.clipboard')) + } +} + +const openAssetLinkModal = () => { + assetLinkDraft.value = selectedDeviceDraft.sourceRef || undefined + if (!inventoryOptions.value.length) buildInventoryOptions() + assetLinkModalVisible.value = true +} + +const closeAssetLinkModal = () => { + assetLinkModalVisible.value = false +} + +const submitAssetLinkModal = async () => { + selectedDeviceDraft.sourceRef = assetLinkDraft.value || null + selectedDeviceHost.value = null + if (selectedDeviceDraft.sourceRef && String(selectedDeviceDraft.sourceRef).startsWith('host:')) { + const hostId = String(selectedDeviceDraft.sourceRef).split(':')[1] || '' + if (hostId) { + try { + selectedDeviceHost.value = await fetchHostById(hostId) + } catch (e) { + selectedDeviceHost.value = null + } + } + } + closeAssetLinkModal() +} + +const openQuickLink = (link) => { + if (link?.url) window.open(link.url, '_blank') +} const updateSidePanePosition = () => { const layout = rackDetailLayoutRef.value @@ -877,6 +1824,9 @@ watch(zoomPercent, (val) => { watch(selectedDevice, async (device) => { nextTick(() => updateSidePanePosition()) selectedDeviceHost.value = null + deviceInfoActiveTab.value = 'summary' + syncSelectedDeviceDraft(device) + syncSideMemoFields(device) const sourceRef = String(device?.sourceRef || '') if (!sourceRef.startsWith('host:')) return const hostId = sourceRef.split(':')[1] || '' @@ -968,22 +1918,27 @@ const fetchRackData = () => { const saveRackData = () => { if (!currentZoneId.value) { message.error(t('rackDiagram.msg.noZoneToSave')) - return + return Promise.resolve(false) + } + if (selectedDevice.value && activeInlineField.value) { + applyInlineDeviceChangesToRack() } saving.value = true const jsonContent = JSON.stringify(parsedRacks.value) - api('updateRackLayout', { + return api('updateRackLayout', { zoneid: currentZoneId.value, name: 'default', content: jsonContent }).then(json => { message.success(t('rackDiagram.msg.rackSaved')) isDirty.value = false // 저장 성공 시 변경 상태 뱃지 숨김 + return true }).catch(error => { console.error('랙 데이터 저장 실패:', error) message.error(t('rackDiagram.msg.rackSaveFailed')) + return false }).finally(() => { saving.value = false }) @@ -1299,6 +2254,28 @@ const selectDevice = (rIndex, iIndex) => { const clearSelectedDevice = () => { selectedDeviceRackIndex.value = -1 selectedDeviceItemIndex.value = -1 + activeInlineField.value = '' + selectedDeviceDraftBaseline.value = '' +} + +const openSelectedDeviceModal = () => { + if (selectedDeviceRackIndex.value < 0 || selectedDeviceItemIndex.value < 0) return + if (activeInlineField.value) finishInlineEdit() + openDeviceModal(selectedDeviceRackIndex.value, selectedDeviceItemIndex.value) +} + +const isSelectedItem = (rIndex, iIndex) => { + return selectedDeviceRackIndex.value === rIndex && selectedDeviceItemIndex.value === iIndex +} + +const isSelectedRulerUnit = (rack, rIndex, rulerIndex) => { + if (selectedDeviceRackIndex.value !== rIndex || selectedDeviceItemIndex.value < 0) return false + const item = rack?.items?.[selectedDeviceItemIndex.value] + if (!item || item.type === 'gap') return false + const itemTopIndex = rack.items + .slice(0, selectedDeviceItemIndex.value) + .reduce((sum, current) => sum + Number(current.height || 0), 0) + 1 + return rulerIndex >= itemTopIndex && rulerIndex < itemTopIndex + Number(item.height || 0) } const getRackUsedU = (rack) => { @@ -1314,6 +2291,23 @@ const getRackUsagePercent = (rack) => { if (!total) return 0 return Math.round((getRackUsedU(rack) / total) * 100) } +const rackAggregate = computed(() => { + const racks = parsedRacks.value || [] + const totalRacks = racks.length + const totalDevices = racks.reduce((sum, rack) => sum + getRackDeviceCount(rack), 0) + const totalUsedU = racks.reduce((sum, rack) => sum + getRackUsedU(rack), 0) + const totalHeight = racks.reduce((sum, rack) => sum + Number(rack?.totalHeight || 0), 0) + const totalFreeU = Math.max(0, totalHeight - totalUsedU) + const averageUsage = totalHeight ? Math.round((totalUsedU / totalHeight) * 100) : 0 + return { + totalRacks, + totalDevices, + totalUsedU, + totalHeight, + totalFreeU, + averageUsage + } +}) const formatRackDate = (value) => { if (!value) return '-' const date = new Date(value) @@ -1388,7 +2382,8 @@ const deviceForm = reactive({ height: 1, customType: '', memo: '', - sourceRef: undefined + sourceRef: undefined, + hardwareInfo: createEmptyHardwareInfo() }) const inventoryLoading = ref(false) @@ -1402,12 +2397,32 @@ const hostVmFallbackList = ref([]) const dragSource = ref({ rIndex: -1, iIndex: -1 }) const quickLinksError = ref('') const quickLinkRows = ref([]) +const deviceSpecColumns = computed(() => ([ + { title: t('rackDiagram.memoKey'), key: 'key', dataIndex: 'key', width: '40%' }, + { title: t('rackDiagram.memoValue'), key: 'value', dataIndex: 'value' }, + { title: t('rackDiagram.quickLinkAction'), key: 'action', dataIndex: 'action', width: 56 } +])) const quickLinkColumns = computed(() => ([ { title: t('rackDiagram.quickLinkName'), key: 'label', dataIndex: 'label', width: '35%' }, { title: t('rackDiagram.quickLinkUrl'), key: 'url', dataIndex: 'url' }, { title: t('rackDiagram.quickLinkAction'), key: 'action', dataIndex: 'action', width: 56 } ])) +const addDeviceSpecRow = () => { + deviceSpecRows.value.push(makeMemoRow()) +} + +const removeDeviceSpecRow = (index) => { + deviceSpecRows.value.splice(index, 1) + if (!deviceSpecRows.value.length) deviceSpecRows.value.push(makeMemoRow()) +} + +const getCleanSpecRows = (rows) => { + return rows + .map(row => ({ key: String(row.key || '').trim(), value: String(row.value || '').trim() })) + .filter(row => row.key || row.value) +} + const makeQuickLinkRow = (label = '', url = '') => ({ key: `${Date.now()}-${Math.random().toString(16).slice(2)}`, label, @@ -1423,7 +2438,7 @@ const removeQuickLinkRow = (index) => { if (!quickLinkRows.value.length) quickLinkRows.value.push(makeQuickLinkRow()) } // 샘플 VM 목록 -const ENABLE_VM_FALLBACK_MOCK = true +const ENABLE_VM_FALLBACK_MOCK = false const HOST_ACTIVE_VM_STATES = new Set(['running', 'starting', 'stopping', 'migrating']) const buildVmMockList = (hostId, hostName = 'sample') => { @@ -1454,18 +2469,6 @@ const filteredHostVmList = computed(() => { return merged.filter(vm => HOST_ACTIVE_VM_STATES.has(String(vm?.state || '').toLowerCase())) }) -const mergeAssetMemo = (existingMemo, linesToAdd) => { - const memo = existingMemo || '' - const marker = '[LinkedAsset]' - const cleaned = memo - .split('\n') - .filter(line => !line.startsWith(marker)) - .join('\n') - .trim() - const linked = linesToAdd.map(line => `${marker} ${line}`).join('\n') - return [cleaned, linked].filter(Boolean).join('\n') -} - const buildInventoryOptions = async () => { if (!currentZoneId.value) return inventoryLoading.value = true @@ -1497,18 +2500,8 @@ const handleSourceChange = (value) => { const selected = inventoryOptions.value.find(o => o.value === value) if (!selected) return - if (!deviceForm.label || Object.values(defaultLabelKeys).map(key => t(key)).includes(deviceForm.label)) { - deviceForm.label = selected.meta.name - } - - const lines = [ - `Type=${selected.meta.kind}`, - `Name=${selected.meta.name}`, - `Id=${selected.meta.id}` - ] - if (selected.meta.ip) lines.push(`IP=${selected.meta.ip}`) - deviceForm.memo = mergeAssetMemo(deviceForm.memo, lines) -} + deviceForm.label = selected.meta?.name || selected.label || deviceForm.label +} const getLinkedHostId = (item) => { if (!item?.sourceRef || typeof item.sourceRef !== 'string') return null @@ -1628,6 +2621,94 @@ const placeDeviceAtStartU = (rack, gapIndex, startU, device) => { return true } +const buildInlineDeviceItem = () => { + if (!selectedDevice.value) return null + const rawLabel = String(selectedDeviceDraft.label || '').trim() + const finalLabel = rawLabel || getDefaultLabel(selectedDevice.value.type) || t('rackDiagram.newDevice') + const height = Number(selectedDeviceDraft.height || 0) + + if (selectedDevice.value.type !== 'blank' && !finalLabel) { + message.warning(t('rackDiagram.msg.enterDeviceName')) + return null + } + if (finalLabel.length > 60) { + message.warning(t('rackDiagram.msg.deviceNameMax')) + return null + } + if (!Number.isInteger(height) || height <= 0) { + message.warning(t('rackDiagram.msg.deviceHeightInteger')) + return null + } + + const cleanHardwareInfo = validateHardwareInfo(selectedDeviceDraft.hardwareInfo) + if (!cleanHardwareInfo) return null + + const quickLinkParsed = parseQuickLinksRowsWithValidation(selectedDeviceDraft.quickLinks) + if (quickLinkParsed.errors.length > 0) { + message.warning(quickLinkParsed.errors[0]) + activeInlineField.value = 'quickLinks' + return null + } + + return { + ...selectedDevice.value, + label: finalLabel, + height, + customType: String(selectedDeviceDraft.customType || '').trim(), + memo: stripLinkedAssetMemo(selectedDeviceDraft.memo), + hardwareInfo: cleanHardwareInfo, + specs: getCleanSpecRows(selectedDeviceDraft.specs), + sourceRef: selectedDevice.value.type === 'server' ? (selectedDeviceDraft.sourceRef || null) : null, + quickLinks: quickLinkParsed.links + } +} + +const applyInlineDeviceChangesToRack = () => { + if (!selectedDevice.value || selectedDeviceRackIndex.value < 0 || selectedDeviceItemIndex.value < 0) return + const rack = parsedRacks.value[selectedDeviceRackIndex.value] + const oldItem = rack?.items?.[selectedDeviceItemIndex.value] + if (!rack || !oldItem || oldItem.type === 'gap') return + + const newItem = buildInlineDeviceItem() + if (!newItem) return + + const desiredBottomU = Number(selectedDeviceDraft.startU || 1) + const desiredStartIndex = Number(rack.totalHeight || 0) - (desiredBottomU + Number(newItem.height || 0) - 1) + if (!Number.isInteger(desiredStartIndex) || desiredStartIndex < 0) { + message.warning(t('rackDiagram.msg.notEnoughGapBelow')) + return + } + + const backupItems = JSON.stringify(rack.items) + const token = `${Date.now()}-${Math.random().toString(36).slice(2)}` + const deviceToPlace = { ...newItem, __inlineToken: token } + + rack.items.splice(selectedDeviceItemIndex.value, 1, { type: 'gap', height: Number(oldItem.height || 0) }) + compactGaps(rack) + + const best = findBestStartUInRack(rack, Number(deviceToPlace.height || 0), desiredStartIndex) + if (!best || best.startU !== desiredStartIndex) { + rack.items = JSON.parse(backupItems) + message.warning(t('rackDiagram.msg.notEnoughGapBelow')) + return + } + + const placed = placeDeviceAtStartU(rack, best.index, best.startU, deviceToPlace) + if (!placed) { + rack.items = JSON.parse(backupItems) + message.warning(t('rackDiagram.msg.dropCalcFailed')) + return + } + + const newIndex = rack.items.findIndex(item => item.__inlineToken === token) + if (newIndex > -1) { + delete rack.items[newIndex].__inlineToken + selectedDeviceItemIndex.value = newIndex + } + syncSelectedDeviceDraft(rack.items[selectedDeviceItemIndex.value], false) + nextTick(() => updateSidePanePosition()) +} + const onDropRackFrame = (targetRIndex, event) => { const { rIndex: sourceRIndex, iIndex: sourceIIndex } = dragSource.value dragSource.value = { rIndex: -1, iIndex: -1 } @@ -1727,32 +2808,40 @@ const openLinkedHostVmModal = async (item) => { const host = await fetchHostById(hostId) hostVmModalTitle.value = `${t('rackDiagram.hostVmList')} - ${host?.name || host?.hostname || hostId}` const hostIdStr = String(hostId) + const isActiveHostVm = (vm) => { + const vmState = String(vm?.state || '').toLowerCase() + return HOST_ACTIVE_VM_STATES.has(vmState) + } const isVmAssignedToHost = (vm) => { const vmHostId = String(vm?.hostid || '') - const vmState = String(vm?.state || '').toLowerCase() // 정책: 현재 host_id가 해당 호스트 + 활성 상태 VM만 표시 - return vmHostId === hostIdStr && HOST_ACTIVE_VM_STATES.has(vmState) + return vmHostId === hostIdStr && isActiveHostVm(vm) } - - // 1) hostid 직접 조회 - const directJson = await api('listVirtualMachines', { + const readVmList = (json) => json?.listvirtualmachinesresponse?.virtualmachine || [] + const hostVmListParams = { hostid: hostId, listall: true, - projectid: '-1', - details: 'min', - pagesize: 500 - }) - let vms = (directJson?.listvirtualmachinesresponse?.virtualmachine || []).filter(isVmAssignedToHost) + details: 'group,nics,secgrp,tmpl,servoff,diskoff,iso,volume,affgrp,backoff', + isvnf: false, + page: 1, + pagesize: 500, + showIcon: true + } + + // 호스트 상세 메뉴의 VM 목록 호출과 동일한 파라미터를 사용한다. + const directJson = await api('listVirtualMachines', hostVmListParams) + let vms = readVmList(directJson).filter(isActiveHostVm) // 2) hostid 조회가 비면 전체에서 host 매핑 기준으로 재탐색 if (!vms.length) { const allJson = await api('listVirtualMachines', { listall: true, - projectid: '-1', - details: 'min', + details: hostVmListParams.details, + isvnf: false, + page: 1, pagesize: 500 }) - const allVms = allJson?.listvirtualmachinesresponse?.virtualmachine || [] + const allVms = readVmList(allJson) vms = allVms.filter(isVmAssignedToHost) } @@ -1794,6 +2883,36 @@ const isRunningVm = (vm) => { return String(vm?.state || '').toLowerCase() === 'running' } +const collectVmIpAddresses = (vm) => { + const nics = Array.isArray(vm?.nic) ? vm.nic : [] + const ips = [] + const pushIp = (ip) => { + const value = String(ip || '').trim() + if (value && !ips.includes(value)) ips.push(value) + } + + const defaultNic = nics.find(nic => nic?.isdefault === true || String(nic?.isdefault).toLowerCase() === 'true') + pushIp(defaultNic?.ipaddress) + + const guestNic = nics.find(nic => String(nic?.type || '').toLowerCase() === 'guest' && nic?.ipaddress) + pushIp(guestNic?.ipaddress) + + nics.forEach(nic => { + pushIp(nic?.ipaddress) + const secondaryIps = Array.isArray(nic?.secondaryip) ? nic.secondaryip : [] + secondaryIps.forEach(ip => pushIp(ip?.ipaddress || ip?.ip)) + }) + + return ips +} + +const getVmPrimaryIpText = (vm) => { + const ips = collectVmIpAddresses(vm) + if (!ips.length) return '-' + const moreCount = ips.length - 1 + return moreCount > 0 ? `${ips[0]} ${t('rackDiagram.moreIpCount', { count: moreCount })}` : ips[0] +} + const goToVmDetail = (vm) => { const vmId = vm?.id if (!vmId) return @@ -1864,10 +2983,12 @@ const openDeviceModal = (rIndex, iIndex) => { if (item.type === 'gap') { deviceForm.type = 'server' deviceForm.label = '' - deviceForm.height = 1 + deviceForm.height = Math.min(2, maxAllowedHeight.value) deviceForm.customType = '' deviceForm.memo = '' deviceForm.sourceRef = undefined + assignHardwareInfo(deviceForm.hardwareInfo, null) + deviceSpecRows.value = [makeMemoRow()] quickLinkRows.value = [makeQuickLinkRow()] quickLinksError.value = '' } else { @@ -1876,8 +2997,10 @@ const openDeviceModal = (rIndex, iIndex) => { deviceForm.label = item.label || '' deviceForm.height = item.height deviceForm.customType = item.customType || '' - deviceForm.memo = item.memo || '' + deviceForm.memo = getDeviceFreeMemo(item) deviceForm.sourceRef = item.sourceRef || undefined + assignHardwareInfo(deviceForm.hardwareInfo, item.hardwareInfo) + deviceSpecRows.value = getDeviceSpecRows(item).length ? getDeviceSpecRows(item) : [makeMemoRow()] quickLinkRows.value = getQuickLinks(item).length ? getQuickLinks(item).map(link => makeQuickLinkRow(link.label || '', link.url || '')) : [makeQuickLinkRow()] @@ -1898,6 +3021,9 @@ const handleTypeChange = (newType) => { if (isLabelEmpty || isLabelDefault) { deviceForm.label = getDefaultLabel(newType) || '' } + if (newType === 'server') { + deviceForm.height = Math.min(2, maxAllowedHeight.value) + } if (newType !== 'server') { deviceForm.sourceRef = undefined } @@ -1938,19 +3064,24 @@ const submitDeviceModal = () => { message.warning(t('rackDiagram.msg.deviceHeightInteger')) return } + const cleanHardwareInfo = validateHardwareInfo(deviceForm.hardwareInfo) + if (!cleanHardwareInfo) return const quickLinkParsed = parseQuickLinksRowsWithValidation(quickLinkRows.value) if (quickLinkParsed.errors.length > 0) { quickLinksError.value = quickLinkParsed.errors[0] return } quickLinksError.value = '' + const cleanSpecs = getCleanSpecRows(deviceSpecRows.value) const newItem = { type: deviceForm.type, label: finalLabel, height: deviceForm.height, customType: finalCustomType, - memo: deviceForm.memo, + memo: stripLinkedAssetMemo(deviceForm.memo), + hardwareInfo: cleanHardwareInfo, + specs: cleanSpecs, sourceRef: deviceForm.sourceRef || null, quickLinks: quickLinkParsed.links } @@ -1997,6 +3128,9 @@ const submitDeviceModal = () => { } } } + if (selectedDeviceRackIndex.value === rIndex && selectedDeviceItemIndex.value === iIndex && rack.items[iIndex]?.type !== 'gap') { + syncSelectedDeviceDraft(rack.items[iIndex]) + } closeDeviceModal() } @@ -2592,17 +3726,21 @@ onBeforeUnmount(() => { flex-wrap: wrap !important; gap: 12px; /* 줄바꿈 되었을 때 위아래 여백 확보 */ + --rack-toolbar-control-height: 22px; background: #ffffff; padding: 12px 16px; border-radius: 8px; border: 1px solid #e8e8e8; - box-shadow: 0 2px 4px rgba(0,0,0,0.02); + box-shadow: 0 1px 2px rgba(0,0,0,0.06); margin-bottom: 16px; } -.toolbar-left, .toolbar-right { +.toolbar-left, +.toolbar-actions, +.toolbar-zoom { display: flex; align-items: center; + min-height: 32px; } .toolbar-detail { @@ -2613,235 +3751,1097 @@ onBeforeUnmount(() => { min-width: 0; } -/* 버튼 사이 구분선 스타일 */ -.toolbar-divider { - background-color: #d9d9d9; - height: 20px; - margin: 0 4px; +.toolbar-actions { + flex: 0 0 auto; } -/* 화면 배율 텍스트 */ -.zoom-label { - font-weight: bold; - color: #595959; - margin-right: 8px; - font-size: 13px; +.toolbar-zoom { + flex: 0 0 auto; + gap: 8px; + margin-left: 12px; } -/* 랙 캔버스 배경 */ -.rack-canvas { - overflow: visible; - min-height: 600px; - padding: 20px; - scrollbar-width: thin; - scrollbar-color: #888 #eef0f4; - background: radial-gradient(circle, #f0f2f5 0%, #e6e9ed 100%); +.toolbar-container :deep(.ant-btn), +.toolbar-container :deep(.ant-input-number) { + height: 32px; } -.rack-list-view { - min-height: 600px; - padding: 8px 4px 12px; +.toolbar-left :deep(.ant-space), +.toolbar-left :deep(.ant-space-item) { + display: flex; + align-items: center; + min-width: 0; + height: 20px; } -.rack-list-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: 12px; +.toolbar-left :deep(.ant-input-search) { + width: 100%; } -.rack-list-card { - border-radius: 10px; - position: relative; - min-height: 218px; - transition: opacity 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; +.toolbar-left :deep(.ant-input-search .ant-input) { + height: var(--rack-toolbar-control-height); + line-height: calc(var(--rack-toolbar-control-height) - 2px); + padding-top: 4px; + padding-bottom: 4px; + box-sizing: border-box; } -.rack-list-card :deep(.ant-card-body) { - height: 100%; +.toolbar-left :deep(.ant-input-search .ant-input-group-addon) { + height: var(--rack-toolbar-control-height); + padding: 0; + line-height: calc(var(--rack-toolbar-control-height) - 2px); + vertical-align: top; + box-sizing: border-box; } -.rack-list-card-actions { - position: absolute; - top: 8px; - right: 8px; - z-index: 2; +.toolbar-left :deep(.ant-input-search-button) { + display: inline-flex; + align-items: center; + justify-content: center; + height: var(--rack-toolbar-control-height); + padding: 0; + line-height: calc(var(--rack-toolbar-control-height) - 2px); + box-sizing: border-box; } -.rack-list-more-btn { - color: #64748b !important; +.rack-diagram-root :deep(.ant-input-search > .ant-input-group > .ant-input-group-addon:last-child .ant-input-search-button:not(.ant-btn-primary)) { + height: 32px; } -.rack-list-more-btn:hover { - color: #334155 !important; - background: #f1f5f9 !important; +.toolbar-container :deep(.ant-input-number-input) { + height: var(--rack-toolbar-control-height); + padding: 0 8px; + line-height: calc(var(--rack-toolbar-control-height) - 2px); + box-sizing: border-box; } -.rack-list-card-match { - position: absolute; - top: 10px; - right: 10px; +.rack-diagram-root :deep(.ant-input-number-sm input) { + height: var(--rack-toolbar-control-height); + padding: 0 8px; } -.rack-list-card--matched { - border-color: #91caff; - box-shadow: 0 0 0 1px rgba(22, 119, 255, 0.18) inset; +.toolbar-container :deep(.ant-btn) { + display: inline-flex; + align-items: center; + justify-content: center; } -.rack-list-card--dimmed { - opacity: 0.52; +.autogen-action-dropdown__trigger { + display: inline-block; } -.add-rack-btn { - border-radius: 8px !important; - font-weight: 600; - box-shadow: 0 2px 8px rgba(22, 119, 255, 0.22); +.autogen-action-dropdown__button { + display: inline-flex; + align-items: center; + gap: 4px; } -.rack-list-card-title { - font-size: 16px; - font-weight: 700; - color: #1f2937; - line-height: 1.35; - min-height: 22px; - max-width: 100%; - padding-right: 28px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +.rack-toolbar-action-dropdown__content { + min-width: 156px; + background: #fff; + border-radius: 8px; + border: 1px solid #d9d9d9; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + padding: 8px; } -.rack-list-card-body { +.rack-toolbar-action-list { display: flex; - align-items: flex-start; - gap: 0; -} - -.rack-list-card-rack-icon { - width: 59px; - height: 59px; - display: inline-flex; - align-items: center; - justify-content: center; - flex: 0 0 59px; - margin-right: 12px; - color: #1f2937; + flex-direction: column; + width: auto; + max-width: none; } -.rack-list-card-rack-icon svg { +.rack-toolbar-action-list :deep(.ant-tooltip-disabled-compatible-wrapper) { width: 100%; - height: 100%; -} - -.rack-list-card-content { - flex: 1; - min-width: 0; - padding-right: 34px; } -.rack-list-card-meta { - margin-top: 4px; - font-size: 13px; - color: #6b7280; - display: block; - min-width: 0; +.rack-toolbar-action-list :deep(.action-button-item--dataview) { + display: flex !important; + align-items: center; + justify-content: flex-start; + width: 100%; + margin-left: 0 !important; + border: none; + padding-left: 12px; + padding-right: 12px; } -.rack-list-card-meta-location-row { - margin-top: 2px; +.rack-toolbar-action-list :deep(.action-button-item--dataview:not(.ant-btn-disabled):hover), +.rack-toolbar-action-list :deep(.action-button-item--dataview:not(.ant-btn-disabled):focus) { + background-color: #e6f4ff; + border-color: transparent; + color: #0958d9; } -.rack-list-card-location { - display: inline-block; - width: 100%; - max-width: 100%; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +.rack-toolbar-action-list :deep(.action-button-item__icon) { + font-size: 16px; } -.rack-list-card-usage { - margin-top: 12px; - font-size: 15px; +.rack-toolbar-action-list :deep(.action-button-item__label) { + margin-left: 8px; font-weight: 500; - color: #1f2937; } -.rack-list-progress { - margin-top: 10px; +.rack-toolbar-action-list :deep(.action-button-item--dataview:not(.ant-btn-disabled):hover .action-button-item__label), +.rack-toolbar-action-list :deep(.action-button-item--dataview:not(.ant-btn-disabled):hover .action-button-item__icon), +.rack-toolbar-action-list :deep(.action-button-item--dataview:not(.ant-btn-disabled):focus .action-button-item__label), +.rack-toolbar-action-list :deep(.action-button-item--dataview:not(.ant-btn-disabled):focus .action-button-item__icon) { + color: #0958d9; } -.rack-list-card-usage-detail { - margin-top: 8px; - font-size: 13px; - color: #4b5563; +.rack-toolbar-action-divider { + height: 1px; + margin: 6px 0; + background: #f0f0f0; } -.rack-list-card-extra { - margin-top: 12px; - font-size: 12px; - color: #64748b; +:global(.autogen-action-dropdown .rack-toolbar-action-dropdown__content) { + min-width: 156px; + background: #fff; + border-radius: 8px; + border: 1px solid #d9d9d9; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + padding: 8px; } -.rack-detail-layout { +:global(.autogen-action-dropdown .rack-toolbar-action-list) { display: flex; - gap: 12px; - align-items: flex-start; - position: relative; - min-width: 0; + flex-direction: column; + width: auto; + max-width: none; } -.rack-main-pane { - flex: 1 1 auto; - min-width: 0; - overflow-x: auto; - overflow-y: visible; - padding-bottom: 8px; +:global(.autogen-action-dropdown .rack-toolbar-action-list .ant-tooltip-disabled-compatible-wrapper) { + width: 100%; } -.rack-side-pane-slot { - width: clamp(300px, 24vw, 420px); - flex: 0 0 clamp(300px, 24vw, 420px); - position: relative; - align-self: flex-start; +:global(.autogen-action-dropdown .rack-toolbar-action-list .action-button-item--dataview) { + display: flex !important; + align-items: center; + justify-content: flex-start; + width: 100%; + margin-left: 0 !important; + border: none; + padding-left: 12px; + padding-right: 12px; } -.rack-side-pane { - width: 100%; - position: -webkit-sticky; - position: sticky; - top: 64px; - align-self: flex-start; - height: fit-content; - max-height: none; - overflow: visible; - max-width: 100%; - z-index: 20; +:global(.autogen-action-dropdown .rack-toolbar-action-list .action-button-item--dataview:not(.ant-btn-disabled):hover), +:global(.autogen-action-dropdown .rack-toolbar-action-list .action-button-item--dataview:not(.ant-btn-disabled):focus) { + background-color: #e6f4ff; + border-color: transparent; + color: #0958d9; } -.rack-side-pane.is-fixed { - position: fixed !important; - z-index: 40; +:global(.autogen-action-dropdown .rack-toolbar-action-list .action-button-item__icon) { + font-size: 16px; } -.rack-side-pane.is-bottom { - position: absolute !important; - top: auto !important; - left: 0; - right: 0; - bottom: 0; +:global(.autogen-action-dropdown .rack-toolbar-action-list .action-button-item__label) { + margin-left: 8px; + font-weight: 500; } -.rack-side-pane-card :deep(.ant-card-head) { - position: sticky; - top: 0; - z-index: 2; - background: #fff; +:global(.autogen-action-dropdown .rack-toolbar-action-list .action-button-item--dataview:not(.ant-btn-disabled):hover .action-button-item__label), +:global(.autogen-action-dropdown .rack-toolbar-action-list .action-button-item--dataview:not(.ant-btn-disabled):hover .action-button-item__icon), +:global(.autogen-action-dropdown .rack-toolbar-action-list .action-button-item--dataview:not(.ant-btn-disabled):focus .action-button-item__label), +:global(.autogen-action-dropdown .rack-toolbar-action-list .action-button-item--dataview:not(.ant-btn-disabled):focus .action-button-item__icon) { + color: #0958d9; } -.device-info-row { - margin-bottom: 8px; - font-size: 12px; +:global(.autogen-action-dropdown .rack-toolbar-action-divider) { + height: 1px; + margin: 6px 0; + background: #f0f0f0; +} + +/* 버튼 사이 구분선 스타일 */ +.toolbar-divider { + background-color: #d9d9d9; + height: 20px; + margin: 0 4px; +} + +/* 화면 배율 텍스트 */ +.zoom-label { + font-weight: bold; + color: #595959; + font-size: 13px; + line-height: 32px; + white-space: nowrap; +} + +.zoom-input { + width: 70px; + text-align: center; + flex-shrink: 0; +} + +.zoom-slider-wrap { + width: 120px; + flex-shrink: 0; + display: flex; + align-items: center; + height: 32px; +} + +.zoom-slider-wrap :deep(.ant-slider) { + width: 100%; + margin: 0; +} + +/* 랙 캔버스 배경 */ +.rack-canvas { + overflow: visible; + min-height: 600px; + padding: 20px; + scrollbar-width: thin; + scrollbar-color: #888 #eef0f4; + background: radial-gradient(circle, #f0f2f5 0%, #e6e9ed 100%); +} + +.rack-list-view { + min-height: 600px; + padding: 8px 4px 12px; +} + +.rack-list-summary-card { + margin-bottom: 14px; + border: 1px solid #e5e7eb; + border-radius: 12px; + box-shadow: 0 4px 14px rgba(15, 23, 42, 0.05); +} + +.rack-list-summary-card :deep(.ant-card-body) { + padding: 12px 22px; +} + +.rack-list-summary-title { + color: #111827; + font-size: 15px; + font-weight: 700; + line-height: 1.4; + flex: 0 0 auto; + padding-right: 30px; + margin-right: 16px; + border-right: 1px solid #eef2f7; +} + +.rack-list-summary-metrics { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-width: 0; + width: 100%; +} + +.rack-list-summary-metric { + min-width: 140px; + display: inline-flex; + align-items: center; + gap: 12px; + padding: 0 26px; + flex: 1 1 150px; +} + +.rack-list-summary-metric:nth-of-type(4), +.rack-list-summary-metric:nth-of-type(5) { + flex-basis: 180px; + min-width: 170px; +} + +.rack-list-summary-metric + .rack-list-summary-metric { + border-left: 1px solid #eef2f7; +} + +.rack-list-summary-label { + color: #64748b; + font-size: 12px; + font-weight: 600; + line-height: 1.25; + white-space: nowrap; +} + +.rack-list-summary-value { + color: #0f172a; + font-size: 17px; + font-weight: 700; + line-height: 1.25; + white-space: nowrap; +} + +.rack-list-summary-text { + display: inline-flex; + flex-direction: column; + gap: 3px; + min-width: 0; +} + +.rack-list-summary-icon { + width: 32px; + height: 32px; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 32px; + color: #1677ff; + background: rgba(22, 119, 255, 0.09); +} + +.rack-list-summary-icon :deep(.anticon) { + font-size: 16px; +} + +.rack-list-summary-rack-icon :deep(.rack-card-icon) { + width: 18px; + height: 22px; +} + +.rack-list-summary-progress { + width: 104px; + margin-left: 10px; + flex: 0 0 104px; +} + +.rack-list-summary-progress :deep(.ant-progress-inner) { + background-color: #e5e7eb; +} + +.rack-list-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.rack-list-card { + border: 1px solid #e5e7eb; + border-radius: 8px; + position: relative; + min-height: 250px; + box-shadow: 0 4px 14px rgba(15, 23, 42, 0.04); + transition: opacity 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; +} + +.rack-list-card:hover { + border-color: #bfdbfe; + box-shadow: 0 8px 22px rgba(15, 23, 42, 0.08); +} + +.rack-list-card :deep(.ant-card-body) { + height: 100%; + padding: 24px 26px; +} + +.rack-list-card-actions { + position: absolute; + top: 8px; + right: 8px; + z-index: 2; +} + +.rack-list-more-btn { + color: #64748b !important; +} + +.rack-list-more-btn:hover { + color: #334155 !important; + background: #f1f5f9 !important; +} + +.rack-list-more-btn :deep(.anticon-more svg) { + width: 3em; + height: 3em; +} + +.rack-list-card-match { + position: absolute; + top: 10px; + right: 44px; + z-index: 1; + max-width: calc(100% - 72px); + overflow: hidden; + white-space: nowrap; +} + +.rack-list-card-match :deep(.ant-tag) { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + margin-right: 0; +} + +.rack-list-card--matched { + border-color: #91caff; + box-shadow: 0 0 0 1px rgba(22, 119, 255, 0.18) inset; +} + +.rack-list-card--dimmed { + opacity: 0.52; +} + +.rack-list-card-title { + font-size: 18px; + font-weight: 700; + color: #1f2937; + line-height: 1.35; + min-height: 24px; + max-width: min(100%, 280px); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rack-list-card-body { + display: flex; + align-items: flex-start; + gap: 0; + height: 100%; +} + +.rack-list-card-rack-icon { + width: 59px; + height: 92px; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 59px; + margin-right: 12px; + color: #1f2937; +} + +.rack-list-card-rack-icon svg { + width: 100%; + height: 100%; +} + +.rack-list-card-content { + flex: 1; + min-width: 0; + padding-right: 34px; +} + +.rack-list-card-title-row { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.rack-list-card-status { + display: inline-flex; + align-items: center; + gap: 5px; + flex: 0 0 auto; + color: #334155; + font-size: 13px; + line-height: 18px; +} + +.rack-list-card-status-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: #10b981; +} + +.rack-list-card-usage { + margin-top: 24px; + color: #475569; + font-size: 14px; + font-weight: 600; +} + +.rack-list-card-progress-row { + display: grid; + grid-template-columns: minmax(120px, 1fr) auto; + align-items: center; + gap: 28px; + margin-top: 8px; +} + +.rack-list-card-usage-detail { + color: #475569; + font-size: 15px; + font-weight: 600; + white-space: nowrap; +} + +.rack-list-progress { + min-width: 0; + margin: 0; +} + +.rack-list-card-stats { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0; + margin-top: 24px; + padding-top: 18px; + border-top: 1px solid #e5e7eb; +} + +.rack-list-card-stat { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.rack-list-card-stat + .rack-list-card-stat { + padding-left: 24px; + border-left: 1px solid #e5e7eb; +} + +.rack-list-card-stat-label { + color: #64748b; + font-size: 13px; + font-weight: 600; + white-space: nowrap; +} + +.rack-list-card-stat strong { + color: #0f172a; + font-size: 16px; + line-height: 20px; +} + +.rack-list-card-footer { + display: flex; + align-items: center; + gap: 18px; + margin-top: 20px; + padding-top: 14px; + border-top: 1px solid #e5e7eb; + font-size: 12px; + color: #64748b; + min-width: 0; +} + +.rack-list-card-footer > span { + display: inline-flex; + align-items: center; + gap: 5px; + min-width: 0; + white-space: nowrap; +} + +.rack-list-card-location { + overflow: hidden; + text-overflow: ellipsis; +} + +.rack-list-add-card { + min-height: 250px; + border: 1px dashed #cbd5e1; + border-radius: 8px; + background: rgba(255, 255, 255, 0.62); + color: #0f172a; + cursor: pointer; + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + font-size: 15px; + font-weight: 700; + transition: border-color 0.15s ease, background-color 0.15s ease, color 0.15s ease; +} + +.rack-list-add-card:hover { + border-color: #1677ff; + color: #1677ff; + background: rgba(22, 119, 255, 0.04); +} + +.rack-list-add-icon { + width: 42px; + height: 42px; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + color: #1677ff; + background: rgba(22, 119, 255, 0.10); + font-size: 22px; +} + +.rack-detail-layout { + display: flex; + gap: 12px; + align-items: flex-start; + position: relative; + min-width: 0; +} + +.rack-main-pane { + flex: 1 1 auto; + min-width: 0; + overflow-x: auto; + overflow-y: visible; + padding-bottom: 8px; +} + +.rack-side-pane-slot { + width: clamp(300px, 24vw, 420px); + flex: 0 0 clamp(300px, 24vw, 420px); + position: relative; + align-self: flex-start; +} + +.rack-side-pane { + width: 100%; + position: -webkit-sticky; + position: sticky; + top: 64px; + align-self: flex-start; + height: fit-content; + max-height: none; + overflow: visible; + max-width: 100%; + z-index: 20; +} + +.rack-side-pane.is-fixed { + position: fixed !important; + z-index: 40; +} + +.rack-side-pane.is-bottom { + position: absolute !important; + top: auto !important; + left: 0; + right: 0; + bottom: 0; +} + +.rack-side-pane-card { + border-radius: 8px; + border-color: rgba(0,0,0,0.06); + box-shadow: 0 1px 2px rgba(0,0,0,0.06); + overflow: hidden; +} + +.rack-side-pane-card :deep(.ant-card-head) { + position: sticky; + top: 0; + z-index: 2; + background: #fff; + border-bottom-color: rgba(0, 0, 0, 0.06); +} + +.rack-side-pane-card :deep(.ant-card-body) { + display: flex; + flex-direction: column; + max-height: calc(100vh - 132px); + min-height: 0; + overflow: hidden; +} + +.device-summary-header { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 16px; + margin-bottom: 14px; + border: 1px solid rgba(22, 119, 255, 0.12); + border-radius: 8px; + background: linear-gradient(135deg, rgba(22, 119, 255, 0.08), rgba(22, 119, 255, 0.02)); + box-shadow: 0 1px 2px rgba(0,0,0,0.04); +} + +.device-summary-icon { + width: 32px; + height: 32px; + flex: 0 0 32px; + display: flex; + align-items: center; + justify-content: center; +} + +.device-summary-icon :deep(svg) { + width: 100%; + height: 100%; +} + +.device-summary-main { + min-width: 0; + flex: 1; +} + +.device-summary-title-row { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.device-summary-title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #111827; + font-size: 16px; + font-weight: 700; +} + +.device-summary-badge { + flex: 0 0 auto; + padding: 1px 7px; + border-radius: 10px; + background: #f5f5f5; + color: #595959; + border: 1px solid #d9d9d9; + font-size: 11px; + font-weight: 700; +} + +.device-summary-subtitle { + display: flex; + align-items: center; + gap: 6px; + margin-top: 4px; + color: #374151; + font-size: 13px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.device-status-inline { + display: inline-flex; + align-items: center; + gap: 5px; +} + +.device-status-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: #22c55e; +} + +.device-info-section { + border: 1px solid rgba(0,0,0,0.06); + border-radius: 8px; + background: #fff; + padding: 10px 12px; + margin-bottom: 10px; + box-shadow: 0 1px 2px rgba(0,0,0,0.06); +} + +.device-info-tabs :deep(.ant-tabs-nav) { + flex: 0 0 auto; + margin-bottom: 12px; + border-bottom: 1px solid #e5e7eb; +} + +.device-info-tabs { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; +} + +.device-info-tabs :deep(.ant-tabs-content-holder) { + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +.device-info-tabs :deep(.ant-tabs-content), +.device-info-tabs :deep(.ant-tabs-tabpane) { + height: 100%; + min-height: 0; +} + +.device-info-tabs :deep(.ant-tabs-tabpane) { + overflow: auto; + padding-right: 2px; +} + +.device-info-tabs :deep(.ant-tabs-tab) { + padding: 8px 0; +} + +.device-inline-form { + display: flex; + flex-direction: column; + border-top: 1px solid #edf2f7; +} + +.device-inline-row { + display: grid; + grid-template-columns: 128px minmax(0, 1fr); + align-items: center; + min-height: 34px; + border-bottom: 1px solid #edf2f7; +} + +.device-inline-row > label { + height: 100%; + display: flex; + align-items: center; + padding: 6px 8px; + margin: 0; + color: #475569; + font-size: 13px; + font-weight: 600; + background: #f8fafc; +} + +.device-inline-row > div, +.device-inline-row > .ant-input, +.device-inline-row > .ant-select { + min-width: 0; + margin: 6px 8px; +} + +.device-readonly-value { + color: #334155; + font-size: 13px; + word-break: break-word; +} + +.device-inline-clickable { + cursor: pointer; + border-radius: 4px; + transition: background 0.15s ease, color 0.15s ease; +} + +.device-inline-clickable:hover { + color: #1677ff; + background: rgba(22, 119, 255, 0.06); +} + +.device-inline-title-input { + max-width: 220px; +} + +.device-info-section-title { + margin-bottom: 8px; + color: #111827; + font-size: 13px; + font-weight: 700; +} + +.device-info-section-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; +} + +.device-info-section-heading .device-info-section-title { + margin-bottom: 0; +} + +.device-section-edit-btn { + color: #64748b !important; +} + +.device-section-edit-btn:hover { + color: #1677ff !important; + background: rgba(22, 119, 255, 0.06) !important; +} + +.device-link-section-edit-btn { + float: right; + margin-top: -4px; +} + +.device-info-table { + border-top: 1px solid #edf2f7; +} + +.device-host-compact-table { + margin-top: 10px; +} + +.device-info-table-row { + display: grid; + grid-template-columns: 112px minmax(0, 1fr); + border-bottom: 1px solid #edf2f7; + min-height: 28px; +} + +.device-info-table-row:last-child { + border-bottom: 0; +} + +.device-info-table-row > div { + padding: 6px 8px; + min-width: 0; + color: #334155; + font-size: 13px; + word-break: break-word; +} + +.device-info-table-row > div:first-child { + color: #475569; + font-weight: 600; + background: #f8fafc; +} + +.device-memo-edit-block { + display: flex; + flex-direction: column; + gap: 8px; +} + +.device-memo-subtitle { + color: #475569; + font-size: 12px; + font-weight: 700; +} + +.device-memo-free-title { + margin-top: 4px; +} + +.device-memo-table { + border: 1px solid rgba(0,0,0,0.06); + border-radius: 8px; + overflow: hidden; + background: #fff; +} + +.device-memo-table-head, +.device-memo-table-row { + display: grid; + grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.3fr) 48px; + align-items: center; +} + +.device-memo-table-read .device-memo-table-head, +.device-memo-table-read .device-memo-table-row { + grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.3fr); +} + +.device-memo-table-head { + background: #f8fafc; + color: #475569; + font-size: 12px; + font-weight: 700; +} + +.device-memo-table-head > div, +.device-memo-table-row > * { + padding: 6px; + border-right: 1px solid rgba(0,0,0,0.06); +} + +.device-memo-table-head > div:last-child, +.device-memo-table-row > *:last-child { + border-right: 0; +} + +.device-memo-table-row { + border-top: 1px solid rgba(0,0,0,0.06); + min-height: 34px; +} + +.device-memo-table-read .device-memo-table-row:hover { + background: #fafcff; +} + +.device-memo-cell-text { + min-width: 0; + color: #334155; + font-size: 13px; + line-height: 1.45; + word-break: break-word; + background: transparent; +} + +.device-memo-row-delete { + justify-self: center; +} + +.device-memo-empty { + padding: 10px 0; +} + +.device-memo-empty :deep(.ant-empty) { + margin: 0; +} + +.device-free-memo-read { + min-height: 62px; + padding: 8px 10px; + border: 1px solid rgba(0,0,0,0.06); + border-radius: 8px; + background: #fff; + color: #334155; + font-size: 13px; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} + +.device-linked-asset-card { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 9px; + border: 1px solid rgba(0,0,0,0.06); + border-radius: 8px; + background: #fff; +} + +.device-linked-asset-main { + min-width: 0; + flex: 1; +} + +.device-linked-asset-name { + color: #334155; + font-size: 13px; + font-weight: 600; + line-height: 1.45; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.device-linked-asset-ip { + margin-top: 2px; + color: #64748b; + font-size: 12px; + line-height: 1.4; +} + +.device-linked-asset-copy { + flex: 0 0 auto; +} + +.device-info-copy-value { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-width: 0; +} + +.device-info-copy-value > span, +.device-info-copy-value :deep(.ant-tooltip-open) { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.device-memo-add-btn { + align-self: flex-start; +} + +.device-info-row { + margin-bottom: 8px; + font-size: 12px; color: #374151; word-break: break-word; } @@ -2859,8 +4859,126 @@ onBeforeUnmount(() => { .device-info-actions { display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + flex: 0 0 auto; + position: sticky; + bottom: 0; + z-index: 3; + margin: 12px -12px -12px; + padding: 12px; + border: 1px solid rgba(0,0,0,0.06); + border-left: 0; + border-right: 0; + border-bottom: 0; + border-radius: 8px; + background: #f8fafc; + box-shadow: 0 1px 2px rgba(0,0,0,0.06); +} + +.device-info-actions-title { + flex: 0 0 100%; + color: #111827; + font-size: 13px; + font-weight: 700; +} + +.device-link-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.device-link-group-title { + color: #64748b; + font-size: 12px; + font-weight: 700; +} + +.device-link-group-title-spaced { + margin-top: 4px; + padding-top: 8px; + border-top: 1px solid rgba(0,0,0,0.06); +} + +.device-link-button { + display: flex !important; + align-items: center; + justify-content: space-between !important; + width: 100%; + min-height: 30px; + height: 30px; + padding: 0 10px !important; + border-color: rgba(0,0,0,0.06) !important; + border-radius: 8px !important; + background: transparent !important; + color: #334155 !important; + box-shadow: none !important; +} + +.device-link-button:hover, +.device-link-button:focus { + border-color: rgba(22,119,255,0.18) !important; + background: rgba(22,119,255,0.06) !important; + color: #1677ff !important; +} + +.device-link-button-main { + display: inline-flex; + align-items: center; gap: 8px; + min-width: 0; +} + +.device-link-button-main > span:last-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.device-link-button-arrow { + flex: 0 0 auto; + color: #94a3b8; + font-size: 12px; +} + +.device-infra-asset-actions { margin-top: 12px; + padding: 12px 0 4px; + border-top: 1px solid rgba(0,0,0,0.06); +} + +.device-infra-asset-action-help { + margin-bottom: 8px; + color: #94a3b8; + font-size: 12px; + line-height: 1.4; +} + +.device-link-asset-change-btn { + margin-top: 0; +} + +.device-link-list-read { + display: flex; + flex-direction: column; + gap: 8px; + cursor: pointer; +} + +.device-inline-link-editor { + display: flex; + flex-direction: column; + gap: 8px; +} + +.device-inline-link-row { + display: grid; + grid-template-columns: minmax(0, 0.75fr) minmax(0, 1fr) 34px; + gap: 6px; + align-items: center; } .device-info-close-btn { @@ -2884,17 +5002,6 @@ onBeforeUnmount(() => { color: #334155; } -.device-memo-linked { - background: transparent; - border-radius: 6px; - padding: 0; -} - -.device-memo-linked-title { - display: none; -} - -.device-memo-linked-list, .device-host-info-list { display: flex; flex-direction: column; @@ -2904,6 +5011,43 @@ onBeforeUnmount(() => { color: #334155; } +.device-config-form { + display: flex; + flex-direction: column; + gap: 12px; +} + +.device-config-section { + border-radius: 8px; + border-color: rgba(0,0,0,0.06); + box-shadow: 0 1px 2px rgba(0,0,0,0.04); +} + +.device-config-section :deep(.ant-card-head) { + min-height: 38px; + padding: 0 14px; + border-bottom-color: rgba(0,0,0,0.06); +} + +.device-config-section :deep(.ant-card-head-title) { + padding: 9px 0; + color: #111827; + font-size: 13px; + font-weight: 700; +} + +.device-config-section :deep(.ant-card-body) { + padding: 14px; +} + +.device-config-section :deep(.ant-form-item:last-child) { + margin-bottom: 0; +} + +.device-config-section-form-item :deep(.ant-form-item-label) { + display: none; +} + .quick-links-editor { display: flex; flex-direction: column; @@ -2914,6 +5058,25 @@ onBeforeUnmount(() => { align-self: flex-start; } +.device-hardware-form { + display: flex; + flex-direction: column; + gap: 8px; +} + +.device-hardware-form-row { + display: grid; + grid-template-columns: 128px minmax(0, 1fr); + align-items: center; + gap: 8px; +} + +.device-hardware-form-row > label { + color: #475569; + font-size: 13px; + font-weight: 600; +} + /* 랙 컨테이너 (가로 정렬) */ .rack-container { display: flex; @@ -3035,7 +5198,7 @@ onBeforeUnmount(() => { align-items: center; flex-wrap: nowrap; gap: 6px; - margin-top: 4px; + margin-top: 25px; color: #6b7280; font-size: 14px; font-weight: 500; @@ -3125,6 +5288,14 @@ onBeforeUnmount(() => { border-top: 1px dotted rgba(15, 23, 42, 0.12); } +.ruler-number-selected { + color: #1677ff; + background: rgba(22, 119, 255, 0.06); + border-left: 1px solid #1677ff; + border-top: 1px solid #1677ff; + border-bottom: 1px solid #1677ff; +} + /* 랙 내부 프레임 */ .rack-frame { width: 560px; @@ -3151,6 +5322,21 @@ onBeforeUnmount(() => { margin-bottom: 0; } +.rack-item-hoverable:hover { + background: rgba(15, 23, 42, 0.025); +} + +.rack-item-selected, +.rack-item-selected:hover { + border: 1px solid #1677ff; + background: rgba(22, 119, 255, 0.06); + z-index: 3; +} + +.rack-item-selected::after { + content: none; +} + .rack-item::after { content: ""; position: absolute; @@ -3211,6 +5397,15 @@ onBeforeUnmount(() => { box-shadow: none; } +.device-content-selected, +.device-content-selected:hover { + background: rgba(22, 119, 255, 0.06) !important; +} + +.device-content-selected .device-name-tag { + color: #1677ff; +} + .device-content::before { content: none; } @@ -3418,14 +5613,14 @@ onBeforeUnmount(() => { } .tag-badge { - background: #1f2937; - color: #ffffff; - padding: 2px 6px; + background: #f5f5f5; + color: #595959; + padding: 1px 5px; border-radius: 10px; flex: 0 0 auto; - font-size: 11px; + font-size: 10px; font-weight: 700; - border: 1px solid rgba(255, 255, 255, 0.18); + border: 1px solid #d9d9d9; } /* 액션 버튼 (호버 시에만 표시) */ @@ -3533,7 +5728,7 @@ onBeforeUnmount(() => { border: 1px solid #d9e2ec; border-radius: 10px; background: #f9fbff; - padding: 10px 12px; + padding: 8px 12px; cursor: pointer; transition: border-color 0.15s ease, box-shadow 0.15s ease; } @@ -3560,10 +5755,14 @@ onBeforeUnmount(() => { color: #9ca3af; } +.host-vm-card-inactive .host-vm-ip { + color: #9ca3af; +} + .host-vm-icon { font-size: 22px; color: #3b82f6; - margin-bottom: 6px; + margin-bottom: 4px; } .host-vm-name { @@ -3575,9 +5774,23 @@ onBeforeUnmount(() => { } .host-vm-meta { - margin-top: 4px; + margin-top: 2px; font-size: 12px; color: #6b7280; + line-height: 16px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.host-vm-ip { + margin-top: 2px; + font-size: 12px; + line-height: 16px; + color: #4b5563; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } /* dark mode in this project is attached on body */ @@ -3605,6 +5818,36 @@ onBeforeUnmount(() => { border-color: #3a4654; } +.rack-diagram-root.is-dark .rack-list-summary-card { + background: #1f2732; + border-color: #3a4654; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18); +} + +.rack-diagram-root.is-dark .rack-list-summary-title, +.rack-diagram-root.is-dark .rack-list-summary-label, +.rack-diagram-root.is-dark .rack-list-summary-value { + color: rgba(255, 255, 255, 0.92); +} + +.rack-diagram-root.is-dark .rack-list-summary-desc, +.rack-diagram-root.is-dark .rack-list-summary-label { + color: rgba(255, 255, 255, 0.62); +} + +.rack-diagram-root.is-dark .rack-list-summary-title { + border-right-color: #334155; +} + +.rack-diagram-root.is-dark .rack-list-summary-icon { + background: rgba(22, 119, 255, 0.16); + color: #8cc8ff; +} + +.rack-diagram-root.is-dark .rack-list-summary-metric + .rack-list-summary-metric { + border-left-color: #334155; +} + .rack-diagram-root.is-dark .rack-list-card--matched { border-color: #4c93ff; box-shadow: 0 0 0 1px rgba(76, 147, 255, 0.32) inset; @@ -3627,7 +5870,9 @@ onBeforeUnmount(() => { color: rgba(255, 255, 255, 0.9); } -.rack-diagram-root.is-dark .rack-list-card-meta, +.rack-diagram-root.is-dark .rack-list-card-status, +.rack-diagram-root.is-dark .rack-list-card-stat-label, +.rack-diagram-root.is-dark .rack-list-card-footer, .rack-diagram-root.is-dark .rack-list-card-usage-detail, .rack-diagram-root.is-dark .rack-list-card-extra { color: rgba(255, 255, 255, 0.65); @@ -3637,6 +5882,33 @@ onBeforeUnmount(() => { color: rgba(255, 255, 255, 0.9); } +.rack-diagram-root.is-dark .rack-list-card-stat strong { + color: rgba(255, 255, 255, 0.92); +} + +.rack-diagram-root.is-dark .rack-list-card-stats, +.rack-diagram-root.is-dark .rack-list-card-footer, +.rack-diagram-root.is-dark .rack-list-card-stat + .rack-list-card-stat { + border-color: #334155; +} + +.rack-diagram-root.is-dark .rack-list-add-card { + background: rgba(31, 39, 50, 0.7); + border-color: #475569; + color: rgba(255, 255, 255, 0.86); +} + +.rack-diagram-root.is-dark .rack-list-add-card:hover { + background: rgba(22, 119, 255, 0.10); + border-color: #4c93ff; + color: #8cc8ff; +} + +.rack-diagram-root.is-dark .rack-list-add-icon { + background: rgba(22, 119, 255, 0.16); + color: #8cc8ff; +} + .rack-diagram-root.is-dark .device-info-row { color: rgba(255, 255, 255, 0.78); } @@ -3649,6 +5921,60 @@ onBeforeUnmount(() => { background: #1f2732; } +.rack-diagram-root.is-dark .rack-side-pane-card { + background: #1f2732; + border-color: #3a4654; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35); +} + +.rack-diagram-root.is-dark .device-summary-header, +.rack-diagram-root.is-dark .device-info-section, +.rack-diagram-root.is-dark .device-info-actions, +.rack-diagram-root.is-dark .device-memo-table, +.rack-diagram-root.is-dark .device-free-memo-read, +.rack-diagram-root.is-dark .device-linked-asset-card { + background: #202938; + border-color: rgba(255, 255, 255, 0.08); +} + +.rack-diagram-root.is-dark .device-memo-table-head, +.rack-diagram-root.is-dark .device-inline-row > label, +.rack-diagram-root.is-dark .device-info-table-row > div:first-child { + background: #253144; + color: rgba(255, 255, 255, 0.78); +} + +.rack-diagram-root.is-dark .device-memo-cell-text, +.rack-diagram-root.is-dark .device-free-memo-read, +.rack-diagram-root.is-dark .device-linked-asset-name, +.rack-diagram-root.is-dark .device-readonly-value, +.rack-diagram-root.is-dark .device-info-table-row > div { + color: rgba(255, 255, 255, 0.82); +} + +.rack-diagram-root.is-dark .device-linked-asset-ip { + color: rgba(255, 255, 255, 0.62); +} + +.rack-diagram-root.is-dark .device-summary-badge, +.rack-diagram-root.is-dark .tag-badge { + background: #2b3544; + color: rgba(255, 255, 255, 0.78); + border-color: #465366; +} + +.rack-diagram-root.is-dark .device-memo-table-head > div, +.rack-diagram-root.is-dark .device-memo-table-row > *, +.rack-diagram-root.is-dark .device-memo-table-row, +.rack-diagram-root.is-dark .device-inline-form, +.rack-diagram-root.is-dark .device-inline-row, +.rack-diagram-root.is-dark .device-info-table, +.rack-diagram-root.is-dark .device-info-table-row, +.rack-diagram-root.is-dark .device-info-tabs :deep(.ant-tabs-nav), +.rack-diagram-root.is-dark .device-link-group-title-spaced { + border-color: rgba(255, 255, 255, 0.08); +} + .rack-diagram-root.is-dark .device-info-close-btn { color: rgba(255, 255, 255, 0.7) !important; } @@ -3664,6 +5990,41 @@ onBeforeUnmount(() => { } @media (max-width: 980px) { + .rack-list-grid { + grid-template-columns: 1fr; + } + + .rack-list-summary-metrics { + flex-wrap: wrap; + align-items: flex-start; + row-gap: 8px; + } + + .rack-list-summary-title { + flex: 1 0 100%; + border-right: 0; + padding-right: 0; + margin-right: 0; + } + + .rack-list-summary-metric { + padding: 0 14px 0 0; + } + + .rack-list-summary-metric + .rack-list-summary-metric { + border-left: 0; + } + + .rack-list-card-progress-row { + grid-template-columns: 1fr; + gap: 6px; + } + + .rack-list-card-footer { + flex-wrap: wrap; + gap: 8px 14px; + } + .rack-side-pane-slot { width: 280px; flex: 0 0 280px; @@ -3683,6 +6044,10 @@ onBeforeUnmount(() => { color: rgba(255, 255, 255, 0.65); } +.rack-diagram-root.is-dark .host-vm-ip { + color: rgba(255, 255, 255, 0.72); +} + .rack-diagram-root.is-dark .toolbar-container :deep(.ant-input), .rack-diagram-root.is-dark .toolbar-container :deep(.ant-input-number), .rack-diagram-root.is-dark .toolbar-container :deep(.ant-input-number-input), diff --git a/ui/src/views/infra/zone/components/RackListCardIcon.vue b/ui/src/views/infra/zone/components/RackListCardIcon.vue index 5294f30e4d5b..02afff85c0c3 100644 --- a/ui/src/views/infra/zone/components/RackListCardIcon.vue +++ b/ui/src/views/infra/zone/components/RackListCardIcon.vue @@ -2,66 +2,110 @@ From 8dc5c4546bfd87092af3ca7de7940e39ad4aa539 Mon Sep 17 00:00:00 2001 From: stardom3645 Date: Mon, 29 Jun 2026 13:32:55 +0900 Subject: [PATCH 14/17] =?UTF-8?q?[Mold][=EB=9E=99=20=EC=8B=9C=EA=B0=81?= =?UTF-8?q?=ED=99=94]=20=EB=9E=99=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=ED=98=95,=20=EB=AA=A9=EB=A1=9D=ED=98=95?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/public/locales/en.json | 3 + ui/public/locales/ko_KR.json | 3 + ui/src/views/infra/zone/RackDiagramTab.vue | 619 ++++++++++++++++++--- 3 files changed, 536 insertions(+), 89 deletions(-) diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index e4b596ddc6e8..b24acfef7ae3 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -5271,6 +5271,7 @@ "rackDiagram.msg.cubeLoadFailed": "Failed to load Cube portal info.", "rackDiagram.msg.quickLinkUrlEmpty": "Line {line}: URL is empty. (Use Name: URL or Name|URL)", "rackDiagram.msg.quickLinkUrlInvalid": "Line {line}: Invalid URL. (e.g. https://10.10.12.2:9090 or 10.10.12.2:9090)", +"rackDiagram.msg.selectDeviceType": "Please select device type.", "rackDiagram.msg.enterDeviceName": "Please enter device name.", "rackDiagram.msg.deviceNameMax": "Device name must be 60 characters or less.", "rackDiagram.msg.enterCustomType": "Please enter custom type name.", @@ -5304,5 +5305,7 @@ "rackDiagram.averageUsage": "Average Usage", "rackDiagram.totalUsedU": "Total Used U", "rackDiagram.totalFreeU": "Free U", +"rackDiagram.cardView": "Card", +"rackDiagram.listView": "List", "rackDiagram.countSuffix": "" } diff --git a/ui/public/locales/ko_KR.json b/ui/public/locales/ko_KR.json index f1b3295aad5d..e80436848233 100644 --- a/ui/public/locales/ko_KR.json +++ b/ui/public/locales/ko_KR.json @@ -5273,6 +5273,7 @@ "rackDiagram.msg.cubeLoadFailed": "Cube \ud3ec\ud138 \uc815\ubcf4\ub97c \ubd88\ub7ec\uc624\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4.", "rackDiagram.msg.quickLinkUrlEmpty": "{line}\ud589: URL\uc774 \ube44\uc5b4 \uc788\uc2b5\ub2c8\ub2e4. (\uc608: \uc774\ub984: URL \ub610\ub294 \uc774\ub984|URL)", "rackDiagram.msg.quickLinkUrlInvalid": "{line}\ud589: URL \ud615\uc2dd\uc774 \uc62c\ubc14\ub974\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4. (\uc608: https://10.10.12.2:9090 \ub610\ub294 10.10.12.2:9090)", +"rackDiagram.msg.selectDeviceType": "\uc7a5\ube44 \uc885\ub958\ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694.", "rackDiagram.msg.enterDeviceName": "\uc7a5\ube44\uba85\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694.", "rackDiagram.msg.deviceNameMax": "\uc7a5\ube44\uba85\uc740 60\uc790 \uc774\ud558\ub85c \uc785\ub825\ud574\uc8fc\uc138\uc694.", "rackDiagram.msg.enterCustomType": "\ucee4\uc2a4\ud140 \ud0c0\uc785\uba85\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694.", @@ -5306,5 +5307,7 @@ "rackDiagram.averageUsage": "\ud3c9\uade0 \uc0ac\uc6a9\ub960", "rackDiagram.totalUsedU": "\ucd1d \uc0ac\uc6a9 U", "rackDiagram.totalFreeU": "\uc5ec\uc720 U", +"rackDiagram.cardView": "\uce74\ub4dc\ud615", +"rackDiagram.listView": "\ubaa9\ub85d\ud615", "rackDiagram.countSuffix": "\uac1c" } diff --git a/ui/src/views/infra/zone/RackDiagramTab.vue b/ui/src/views/infra/zone/RackDiagramTab.vue index de32805810ad..cdbf23aa102d 100644 --- a/ui/src/views/infra/zone/RackDiagramTab.vue +++ b/ui/src/views/infra/zone/RackDiagramTab.vue @@ -112,19 +112,21 @@ {{ rackAggregate.totalDevices }}
-
+
{{ t('rackDiagram.averageUsage') }} - {{ rackAggregate.averageUsage }}% + + {{ rackAggregate.averageUsage }}% + + -
@@ -142,7 +144,17 @@
-
+
+ + + {{ t('rackDiagram.cardView') }} + + + {{ t('rackDiagram.listView') }} + + +
+
-
{{ rack.name }}
-
- {{ t('state.ok') }} +
{{ rack.name }}
+
{{ t('rackDiagram.usage') }} {{ getRackUsagePercent(rack) }}%
@@ -236,6 +247,84 @@ {{ t('rackDiagram.addRack') }}
+
+
+
+ +
+
+ + {{ rack.name }} + + + {{ t('rackDiagram.searchMatched', { count: getRackMatchCount(rack) }) }} + +
+
+ {{ t('label.created') }} {{ getRackCreatedDate(rack) }} + + {{ t('rackDiagram.rackLocation') }} {{ getRackLocation(rack) }} + +
+
+
+
+ {{ t('rackDiagram.usage') }} + {{ getRackUsagePercent(rack) }}% + + {{ getRackUsedU(rack) }}U / {{ rack.totalHeight }}U +
+
+ {{ t('rackDiagram.freeSpace') }} {{ getRackFreeU(rack) }}U + {{ t('rackDiagram.deviceCount') }} {{ getRackDeviceCount(rack) }}{{ t('rackDiagram.countSuffix') }} +
+
+ + + + + + +
+
+ +
@@ -771,7 +860,12 @@ destroyOnClose > - + @@ -780,7 +874,12 @@ - + - + {{ t('rackDiagram.deviceTypeServer') }} {{ t('rackDiagram.deviceTypeBlade') }} @@ -853,7 +957,13 @@ - + - + - + { // 데이터 모델 const parsedRacks = ref([]) const showRackList = ref(true) +const rackListViewMode = ref('card') const selectedRackIndex = ref(-1) const selectedDeviceRackIndex = ref(-1) const selectedDeviceItemIndex = ref(-1) @@ -1950,8 +2072,46 @@ const rackModalMode = ref('add') // 'add' or 'edit' const targetRackIndex = ref(-1) const rackForm = reactive({ name: '', totalHeight: 42, location: '', createdAt: '' }) +const rackFormErrors = reactive({ name: '', totalHeight: '' }) + +const clearRackFormErrors = () => { + rackFormErrors.name = '' + rackFormErrors.totalHeight = '' +} + +const validateRackModalForm = () => { + clearRackFormErrors() + const rackName = String(rackForm.name || '').trim() + + if (!rackName) { + rackFormErrors.name = t('rackDiagram.msg.enterRackName') + } else if (rackName.length > 60) { + rackFormErrors.name = t('rackDiagram.msg.rackNameMax') + } else { + const duplicateName = parsedRacks.value.some((rack, idx) => { + if (rackModalMode.value === 'edit' && idx === targetRackIndex.value) return false + return String(rack.name || '').trim().toLowerCase() === rackName.toLowerCase() + }) + if (duplicateName) rackFormErrors.name = t('rackDiagram.msg.duplicateRackName') + } + + if (!Number.isInteger(rackForm.totalHeight) || rackForm.totalHeight < 10 || rackForm.totalHeight > 50) { + rackFormErrors.totalHeight = t('rackDiagram.msg.rackHeightRange') + } + + return !rackFormErrors.name && !rackFormErrors.totalHeight +} + +watch(() => rackForm.name, () => { + if (rackFormErrors.name) rackFormErrors.name = '' +}) + +watch(() => rackForm.totalHeight, () => { + if (rackFormErrors.totalHeight) rackFormErrors.totalHeight = '' +}) const openRackModal = (mode, index = -1) => { + clearRackFormErrors() rackModalMode.value = mode targetRackIndex.value = index if (mode === 'edit' && index > -1) { @@ -1970,6 +2130,7 @@ const openRackModal = (mode, index = -1) => { } const closeRackModal = () => { + clearRackFormErrors() rackModalVisible.value = false } @@ -2102,30 +2263,10 @@ const cloneRack = (rIndex) => { // 랙 모달 저장 로직 const submitRackModal = () => { + if (!validateRackModalForm()) return + const rackName = String(rackForm.name || '').trim() const rackLocation = String(rackForm.location || '').trim() - if (!rackName) { - message.warning(t('rackDiagram.msg.enterRackName')) - return - } - if (rackName.length > 60) { - message.warning(t('rackDiagram.msg.rackNameMax')) - return - } - - if (!Number.isInteger(rackForm.totalHeight) || rackForm.totalHeight < 10 || rackForm.totalHeight > 50) { - message.warning(t('rackDiagram.msg.rackHeightRange')) - return - } - - const duplicateName = parsedRacks.value.some((rack, idx) => { - if (rackModalMode.value === 'edit' && idx === targetRackIndex.value) return false - return String(rack.name || '').trim().toLowerCase() === rackName.toLowerCase() - }) - if (duplicateName) { - message.warning(t('rackDiagram.msg.duplicateRackName')) - return - } if (rackModalMode.value === 'add') { parsedRacks.value.push({ @@ -2385,6 +2526,55 @@ const deviceForm = reactive({ sourceRef: undefined, hardwareInfo: createEmptyHardwareInfo() }) +const deviceFormErrors = reactive({ type: '', label: '', customType: '', height: '' }) + +const clearDeviceFormErrors = () => { + deviceFormErrors.type = '' + deviceFormErrors.label = '' + deviceFormErrors.customType = '' + deviceFormErrors.height = '' +} + +const validateDeviceModalForm = () => { + clearDeviceFormErrors() + const rawLabel = String(deviceForm.label || '').trim() + const rawCustomType = String(deviceForm.customType || '').trim() + + if (!deviceForm.type) { + deviceFormErrors.type = t('rackDiagram.msg.selectDeviceType') + } + if (deviceForm.type !== 'blank') { + if (!rawLabel) deviceFormErrors.label = t('rackDiagram.msg.enterDeviceName') + else if (rawLabel.length > 60) deviceFormErrors.label = t('rackDiagram.msg.deviceNameMax') + } + if (deviceForm.type === 'custom') { + if (!rawCustomType) deviceFormErrors.customType = t('rackDiagram.msg.enterCustomType') + else if (rawCustomType.length > 60) deviceFormErrors.customType = t('rackDiagram.msg.customTypeMax') + } + if (!Number.isInteger(deviceForm.height) || deviceForm.height <= 0) { + deviceFormErrors.height = t('rackDiagram.msg.deviceHeightInteger') + } else if (deviceForm.height > maxAllowedHeight.value) { + deviceFormErrors.height = t('rackDiagram.msg.deviceLargerThanGap') + } + + return !deviceFormErrors.type && !deviceFormErrors.label && !deviceFormErrors.customType && !deviceFormErrors.height +} + +watch(() => deviceForm.type, () => { + if (deviceFormErrors.type) deviceFormErrors.type = '' +}) + +watch(() => deviceForm.label, () => { + if (deviceFormErrors.label) deviceFormErrors.label = '' +}) + +watch(() => deviceForm.customType, () => { + if (deviceFormErrors.customType) deviceFormErrors.customType = '' +}) + +watch(() => deviceForm.height, () => { + if (deviceFormErrors.height) deviceFormErrors.height = '' +}) const inventoryLoading = ref(false) const inventoryOptions = ref([]) @@ -2438,7 +2628,7 @@ const removeQuickLinkRow = (index) => { if (!quickLinkRows.value.length) quickLinkRows.value.push(makeQuickLinkRow()) } // 샘플 VM 목록 -const ENABLE_VM_FALLBACK_MOCK = false +const ENABLE_VM_FALLBACK_MOCK = true const HOST_ACTIVE_VM_STATES = new Set(['running', 'starting', 'stopping', 'migrating']) const buildVmMockList = (hostId, hostName = 'sample') => { @@ -2976,6 +3166,7 @@ const getDefaultLabel = (type) => t(defaultLabelKeys[type] || 'rackDiagram.defau // 모달 열기 로직 수정 const openDeviceModal = (rIndex, iIndex) => { + clearDeviceFormErrors() targetRackIndex.value = rIndex targetItemIndex.value = iIndex const item = parsedRacks.value[rIndex].items[iIndex] @@ -3014,6 +3205,7 @@ const openDeviceModal = (rIndex, iIndex) => { // 장비 종류 Select Box 변경 시 라벨 자동 업데이트 const handleTypeChange = (newType) => { + clearDeviceFormErrors() // 사용자가 직접 입력한 라벨이 없거나, 기존 기본 라벨명과 똑같을 때만 새 기본명으로 교체 const isLabelEmpty = !deviceForm.label const isLabelDefault = Object.values(defaultLabelKeys).map(key => t(key)).includes(deviceForm.label) @@ -3030,10 +3222,13 @@ const handleTypeChange = (newType) => { } const closeDeviceModal = () => { + clearDeviceFormErrors() deviceModalVisible.value = false } const submitDeviceModal = () => { + if (!validateDeviceModalForm()) return + const rIndex = targetRackIndex.value const iIndex = targetItemIndex.value const rack = parsedRacks.value[rIndex] @@ -3041,29 +3236,8 @@ const submitDeviceModal = () => { const rawLabel = String(deviceForm.label || '').trim() const rawCustomType = String(deviceForm.customType || '').trim() - const finalLabel = rawLabel || getDefaultLabel(deviceForm.type) || t('rackDiagram.newDevice') + const finalLabel = deviceForm.type === 'blank' ? (rawLabel || getDefaultLabel('blank')) : rawLabel const finalCustomType = rawCustomType || (deviceForm.type === 'custom' ? t('rackDiagram.customUnit') : '') - - if (deviceForm.type !== 'blank' && !finalLabel) { - message.warning(t('rackDiagram.msg.enterDeviceName')) - return - } - if (finalLabel.length > 60) { - message.warning(t('rackDiagram.msg.deviceNameMax')) - return - } - if (deviceForm.type === 'custom' && !rawCustomType) { - message.warning(t('rackDiagram.msg.enterCustomType')) - return - } - if (deviceForm.type === 'custom' && rawCustomType.length > 60) { - message.warning(t('rackDiagram.msg.customTypeMax')) - return - } - if (!Number.isInteger(deviceForm.height) || deviceForm.height <= 0) { - message.warning(t('rackDiagram.msg.deviceHeightInteger')) - return - } const cleanHardwareInfo = validateHardwareInfo(deviceForm.hardwareInfo) if (!cleanHardwareInfo) return const quickLinkParsed = parseQuickLinksRowsWithValidation(quickLinkRows.value) @@ -4100,16 +4274,44 @@ onBeforeUnmount(() => { height: 22px; } +.rack-list-summary-value-row { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; + white-space: nowrap; +} + .rack-list-summary-progress { - width: 104px; - margin-left: 10px; - flex: 0 0 104px; + display: block; + width: 48px; + margin: 0; + flex: 0 0 48px; +} + +.rack-list-summary-progress :deep(.ant-progress-outer) { + display: block; + width: 100%; + padding-right: 0; + margin-right: 0; } .rack-list-summary-progress :deep(.ant-progress-inner) { background-color: #e5e7eb; } +.rack-list-view-switch { + display: flex; + justify-content: flex-end; + margin: -2px 0 12px; +} + +.rack-list-view-switch :deep(.ant-radio-button-wrapper) { + display: inline-flex; + align-items: center; + gap: 5px; +} + .rack-list-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -4230,23 +4432,6 @@ onBeforeUnmount(() => { min-width: 0; } -.rack-list-card-status { - display: inline-flex; - align-items: center; - gap: 5px; - flex: 0 0 auto; - color: #334155; - font-size: 13px; - line-height: 18px; -} - -.rack-list-card-status-dot { - width: 7px; - height: 7px; - border-radius: 50%; - background: #10b981; -} - .rack-list-card-usage { margin-top: 24px; color: #475569; @@ -4356,6 +4541,193 @@ onBeforeUnmount(() => { background: rgba(22, 119, 255, 0.04); } +.rack-list-table { + display: flex; + flex-direction: column; + gap: 10px; +} + +.rack-list-row { + display: grid; + grid-template-columns: minmax(260px, 1.45fr) minmax(260px, 1fr) minmax(180px, 0.7fr) 42px; + align-items: center; + gap: 18px; + min-height: 82px; + padding: 14px 16px; + border: 1px solid #e5e7eb; + border-radius: 8px; + background: #fff; + box-shadow: 0 3px 10px rgba(15, 23, 42, 0.035); + cursor: pointer; + transition: opacity 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease; +} + +.rack-list-row:hover { + border-color: #bfdbfe; + box-shadow: 0 6px 16px rgba(15, 23, 42, 0.07); +} + +.rack-list-row--matched { + border-color: #91caff; + box-shadow: 0 0 0 1px rgba(22, 119, 255, 0.16) inset; +} + +.rack-list-row--dimmed { + opacity: 0.52; +} + +.rack-list-row-main { + display: flex; + align-items: center; + gap: 14px; + min-width: 0; +} + +.rack-list-row-icon { + width: 34px; + height: 50px; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 34px; + color: #1f2937; +} + +.rack-list-row-icon svg { + width: 100%; + height: 100%; +} + +.rack-list-row-title-wrap { + min-width: 0; +} + +.rack-list-row-title-line { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.rack-list-row-title { + display: inline-block; + max-width: 320px; + overflow: hidden; + color: #111827; + font-size: 16px; + font-weight: 700; + line-height: 22px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rack-list-row-match { + flex: 0 1 auto; + max-width: 110px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rack-list-row-meta { + display: flex; + align-items: center; + gap: 14px; + min-width: 0; + margin-top: 7px; + color: #64748b; + font-size: 12px; +} + +.rack-list-row-meta > span { + display: inline-flex; + align-items: center; + gap: 5px; + min-width: 0; + white-space: nowrap; +} + +.rack-list-row-location { + overflow: hidden; + text-overflow: ellipsis; +} + +.rack-list-row-usage { + display: grid; + grid-template-columns: auto auto minmax(90px, 1fr) auto; + align-items: center; + gap: 10px; + min-width: 0; +} + +.rack-list-row-label { + color: #64748b; + font-size: 12px; + font-weight: 600; + white-space: nowrap; +} + +.rack-list-row-usage strong { + color: #0f172a; + font-size: 18px; + line-height: 22px; + white-space: nowrap; +} + +.rack-list-row-progress { + min-width: 0; + margin: 0; +} + +.rack-list-row-used { + color: #475569; + font-size: 13px; + font-weight: 600; + white-space: nowrap; +} + +.rack-list-row-stats { + display: flex; + flex-direction: column; + gap: 7px; + color: #64748b; + font-size: 12px; + white-space: nowrap; +} + +.rack-list-row-stats strong { + color: #0f172a; + font-size: 14px; + margin-left: 4px; +} + +.rack-list-row-actions { + display: flex; + justify-content: flex-end; +} + +.rack-list-add-row { + min-height: 58px; + border: 1px dashed #cbd5e1; + border-radius: 8px; + background: rgba(255, 255, 255, 0.62); + color: #0f172a; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + font-size: 14px; + font-weight: 700; + transition: border-color 0.15s ease, background-color 0.15s ease, color 0.15s ease; +} + +.rack-list-add-row:hover { + border-color: #1677ff; + color: #1677ff; + background: rgba(22, 119, 255, 0.04); +} + .rack-list-add-icon { width: 42px; height: 42px; @@ -5870,7 +6242,6 @@ onBeforeUnmount(() => { color: rgba(255, 255, 255, 0.9); } -.rack-diagram-root.is-dark .rack-list-card-status, .rack-diagram-root.is-dark .rack-list-card-stat-label, .rack-diagram-root.is-dark .rack-list-card-footer, .rack-diagram-root.is-dark .rack-list-card-usage-detail, @@ -5909,6 +6280,50 @@ onBeforeUnmount(() => { color: #8cc8ff; } +.rack-diagram-root.is-dark .rack-list-row { + background: #1f2732; + border-color: #3a4654; + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.14); +} + +.rack-diagram-root.is-dark .rack-list-row:hover { + border-color: #4c93ff; +} + +.rack-diagram-root.is-dark .rack-list-row--matched { + border-color: #4c93ff; + box-shadow: 0 0 0 1px rgba(76, 147, 255, 0.32) inset; +} + +.rack-diagram-root.is-dark .rack-list-row-title, +.rack-diagram-root.is-dark .rack-list-row-usage strong, +.rack-diagram-root.is-dark .rack-list-row-stats strong { + color: rgba(255, 255, 255, 0.92); +} + +.rack-diagram-root.is-dark .rack-list-row-icon { + color: rgba(255, 255, 255, 0.9); +} + +.rack-diagram-root.is-dark .rack-list-row-meta, +.rack-diagram-root.is-dark .rack-list-row-label, +.rack-diagram-root.is-dark .rack-list-row-used, +.rack-diagram-root.is-dark .rack-list-row-stats { + color: rgba(255, 255, 255, 0.65); +} + +.rack-diagram-root.is-dark .rack-list-add-row { + background: rgba(31, 39, 50, 0.7); + border-color: #475569; + color: rgba(255, 255, 255, 0.86); +} + +.rack-diagram-root.is-dark .rack-list-add-row:hover { + background: rgba(22, 119, 255, 0.10); + border-color: #4c93ff; + color: #8cc8ff; +} + .rack-diagram-root.is-dark .device-info-row { color: rgba(255, 255, 255, 0.78); } @@ -6025,6 +6440,32 @@ onBeforeUnmount(() => { gap: 8px 14px; } + .rack-list-row { + grid-template-columns: minmax(0, 1fr) 42px; + gap: 12px 14px; + } + + .rack-list-row-main, + .rack-list-row-usage, + .rack-list-row-stats { + grid-column: 1 / 2; + } + + .rack-list-row-actions { + grid-column: 2 / 3; + grid-row: 1 / 2; + align-self: start; + } + + .rack-list-row-title { + max-width: 220px; + } + + .rack-list-row-stats { + flex-direction: row; + gap: 18px; + } + .rack-side-pane-slot { width: 280px; flex: 0 0 280px; From b5523aae76d1e3a453fecf4cecae49714115aeb9 Mon Sep 17 00:00:00 2001 From: stardom3645 Date: Fri, 3 Jul 2026 10:44:09 +0900 Subject: [PATCH 15/17] =?UTF-8?q?[Mold][=EB=9E=99=20=EC=8B=9C=EA=B0=81?= =?UTF-8?q?=ED=99=94]=20=EB=8B=A4=ED=81=AC=ED=85=8C=EB=A7=88=20=EC=88=98?= =?UTF-8?q?=EC=A0=95,=20=EC=A0=95=EA=B7=9C=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/public/locales/en.json | 4 +- ui/public/locales/ko_KR.json | 4 +- ui/src/views/infra/InfraSummary.vue | 37 +- ui/src/views/infra/zone/RackDiagramTab.vue | 841 ++++++++++++++++++--- 4 files changed, 769 insertions(+), 117 deletions(-) diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index b24acfef7ae3..fe89ffff02e0 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -5247,7 +5247,8 @@ "rackDiagram.msg.rackLoaded": "Rack layout loaded.", "rackDiagram.msg.rackLoadFailed": "Failed to load rack layout.", "rackDiagram.msg.noZoneToSave": "No zone information to save.", -"rackDiagram.msg.rackSaved": "Rack layout saved to DB.", +"rackDiagram.msg.noChangesToSave": "No changes to save.", +"rackDiagram.msg.rackSaved": "Rack layout saved.", "rackDiagram.msg.rackSaveFailed": "Failed to save rack layout.", "rackDiagram.msg.noRackToSaveImage": "No rack layout to save.", "rackDiagram.msg.exportingImage": "Converting image...", @@ -5278,6 +5279,7 @@ "rackDiagram.msg.customTypeMax": "Custom type name must be 60 characters or less.", "rackDiagram.msg.deviceHeightInteger": "Device height must be an integer of 1 or more.", "rackDiagram.msg.hardwareFieldMax": "{field} must be 128 characters or less.", +"rackDiagram.msg.purchaseDateBeforeMaintenanceEndDate": "Purchase date must be earlier than maintenance end date.", "rackDiagram.msg.deviceLargerThanGap": "Cannot add a device larger than selected gap.", "rackDiagram.msg.notEnoughGapBelow": "Not enough gap below to increase size.", "rackDiagram.msg.notEnoughSpaceInRack": "Not enough free space in rack. ({height}U needed)", diff --git a/ui/public/locales/ko_KR.json b/ui/public/locales/ko_KR.json index e80436848233..66c3d7307bce 100644 --- a/ui/public/locales/ko_KR.json +++ b/ui/public/locales/ko_KR.json @@ -5249,7 +5249,8 @@ "rackDiagram.msg.rackLoaded": "\ub799 \uad6c\uc131\uc744 \ubd88\ub7ec\uc654\uc2b5\ub2c8\ub2e4.", "rackDiagram.msg.rackLoadFailed": "\ub799 \uad6c\uc131\uc744 \ubd88\ub7ec\uc624\ub294\ub370 \uc2e4\ud328\ud588\uc2b5\ub2c8\ub2e4.", "rackDiagram.msg.noZoneToSave": "\uc800\uc7a5\ud560 Zone \uc815\ubcf4\uac00 \uc5c6\uc2b5\ub2c8\ub2e4.", -"rackDiagram.msg.rackSaved": "\ub799 \uad6c\uc131\uc774 DB\uc5d0 \uc548\uc804\ud558\uac8c \uc800\uc7a5\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", +"rackDiagram.msg.noChangesToSave": "\uc800\uc7a5\ud560 \ubcc0\uacbd \uc0ac\ud56d\uc774 \uc5c6\uc2b5\ub2c8\ub2e4.", +"rackDiagram.msg.rackSaved": "\ub799 \uad6c\uc131\uc774 \uc800\uc7a5\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", "rackDiagram.msg.rackSaveFailed": "\ub799 \uad6c\uc131 \uc800\uc7a5\uc5d0 \uc2e4\ud328\ud588\uc2b5\ub2c8\ub2e4.", "rackDiagram.msg.noRackToSaveImage": "\uc800\uc7a5\ud560 \ub799 \uad6c\uc131\uc774 \uc5c6\uc2b5\ub2c8\ub2e4.", "rackDiagram.msg.exportingImage": "\uc774\ubbf8\uc9c0 \ubcc0\ud658 \uc911...", @@ -5280,6 +5281,7 @@ "rackDiagram.msg.customTypeMax": "\ucee4\uc2a4\ud140 \ud0c0\uc785\uba85\uc740 60\uc790 \uc774\ud558\ub85c \uc785\ub825\ud574\uc8fc\uc138\uc694.", "rackDiagram.msg.deviceHeightInteger": "\uc7a5\ube44 \ub192\uc774(U)\ub294 1 \uc774\uc0c1\uc758 \uc815\uc218\ub9cc \uc785\ub825\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.", "rackDiagram.msg.hardwareFieldMax": "{field}\uc740(\ub294) 128\uc790 \uc774\ud558\ub85c \uc785\ub825\ud574\uc8fc\uc138\uc694.", +"rackDiagram.msg.purchaseDateBeforeMaintenanceEndDate": "\uad6c\ub9e4\uc77c\uc740 \uc720\uc9c0\ubcf4\uc218 \ub9cc\ub8cc\uc77c\ubcf4\ub2e4 \ube68\ub77c\uc57c \ud569\ub2c8\ub2e4.", "rackDiagram.msg.deviceLargerThanGap": "\uc120\ud0dd\ud55c \uc5ec\ubc31\ubcf4\ub2e4 \ud070 \uc7a5\ube44\ub294 \ucd94\uac00\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.", "rackDiagram.msg.notEnoughGapBelow": "\uc544\ub798\ucabd \uc5ec\ubc31\uc774 \ubd80\uc871\ud558\uc5ec \ud06c\uae30\ub97c \ub298\ub9b4 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.", "rackDiagram.msg.notEnoughSpaceInRack": "\ub799 \ub0b4\uc5d0 \uc5ec\uc720 \uacf5\uac04\uc774 \ubd80\uc871\ud569\ub2c8\ub2e4. (\ud544\uc694 \uacf5\uac04: {height}U)", diff --git a/ui/src/views/infra/InfraSummary.vue b/ui/src/views/infra/InfraSummary.vue index 8c92941f7f45..094c17c5608a 100644 --- a/ui/src/views/infra/InfraSummary.vue +++ b/ui/src/views/infra/InfraSummary.vue @@ -16,7 +16,7 @@ // under the License.