001/* 002 * VM-Operator 003 * Copyright (C) 2023,2024 Michael N. Lipp 004 * 005 * This program is free software: you can redistribute it and/or modify 006 * it under the terms of the GNU Affero General Public License as 007 * published by the Free Software Foundation, either version 3 of the 008 * License, or (at your option) any later version. 009 * 010 * This program is distributed in the hope that it will be useful, 011 * but WITHOUT ANY WARRANTY; without even the implied warranty of 012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 013 * GNU Affero General Public License for more details. 014 * 015 * You should have received a copy of the GNU Affero General Public License 016 * along with this program. If not, see <https://www.gnu.org/licenses/>. 017 */ 018 019package org.jdrupes.vmoperator.vmviewer; 020 021import com.fasterxml.jackson.annotation.JsonGetter; 022import com.fasterxml.jackson.annotation.JsonProperty; 023import com.fasterxml.jackson.core.JsonProcessingException; 024import com.fasterxml.jackson.databind.ObjectMapper; 025import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; 026import com.google.gson.JsonSyntaxException; 027import freemarker.core.ParseException; 028import freemarker.template.MalformedTemplateNameException; 029import freemarker.template.Template; 030import freemarker.template.TemplateNotFoundException; 031import io.kubernetes.client.util.Strings; 032import java.io.IOException; 033import java.net.Inet4Address; 034import java.net.Inet6Address; 035import java.net.InetAddress; 036import java.net.UnknownHostException; 037import java.time.Duration; 038import java.util.Base64; 039import java.util.Collections; 040import java.util.EnumSet; 041import java.util.HashSet; 042import java.util.List; 043import java.util.Map; 044import java.util.Optional; 045import java.util.ResourceBundle; 046import java.util.Set; 047import java.util.logging.Level; 048import java.util.stream.Collectors; 049import org.bouncycastle.util.Objects; 050import org.jdrupes.vmoperator.common.K8sObserver; 051import org.jdrupes.vmoperator.common.VmDefinition; 052import org.jdrupes.vmoperator.common.VmDefinition.Permission; 053import org.jdrupes.vmoperator.manager.events.ChannelTracker; 054import org.jdrupes.vmoperator.manager.events.GetDisplayPassword; 055import org.jdrupes.vmoperator.manager.events.ModifyVm; 056import org.jdrupes.vmoperator.manager.events.ResetVm; 057import org.jdrupes.vmoperator.manager.events.VmChannel; 058import org.jdrupes.vmoperator.manager.events.VmDefChanged; 059import org.jgrapes.core.Channel; 060import org.jgrapes.core.Components; 061import org.jgrapes.core.Event; 062import org.jgrapes.core.Manager; 063import org.jgrapes.core.annotation.Handler; 064import org.jgrapes.http.Session; 065import org.jgrapes.util.events.ConfigurationUpdate; 066import org.jgrapes.util.events.KeyValueStoreQuery; 067import org.jgrapes.util.events.KeyValueStoreUpdate; 068import org.jgrapes.webconsole.base.Conlet.RenderMode; 069import org.jgrapes.webconsole.base.ConletBaseModel; 070import org.jgrapes.webconsole.base.ConsoleConnection; 071import org.jgrapes.webconsole.base.ConsoleRole; 072import org.jgrapes.webconsole.base.ConsoleUser; 073import org.jgrapes.webconsole.base.WebConsoleUtils; 074import org.jgrapes.webconsole.base.events.AddConletRequest; 075import org.jgrapes.webconsole.base.events.AddConletType; 076import org.jgrapes.webconsole.base.events.AddPageResources.ScriptResource; 077import org.jgrapes.webconsole.base.events.ConletDeleted; 078import org.jgrapes.webconsole.base.events.ConsoleConfigured; 079import org.jgrapes.webconsole.base.events.ConsolePrepared; 080import org.jgrapes.webconsole.base.events.ConsoleReady; 081import org.jgrapes.webconsole.base.events.DeleteConlet; 082import org.jgrapes.webconsole.base.events.NotifyConletModel; 083import org.jgrapes.webconsole.base.events.NotifyConletView; 084import org.jgrapes.webconsole.base.events.OpenModalDialog; 085import org.jgrapes.webconsole.base.events.RenderConlet; 086import org.jgrapes.webconsole.base.events.RenderConletRequestBase; 087import org.jgrapes.webconsole.base.events.SetLocale; 088import org.jgrapes.webconsole.base.events.UpdateConletType; 089import org.jgrapes.webconsole.base.freemarker.FreeMarkerConlet; 090 091/** 092 * The Class VmViewer. The component supports the following 093 * configuration properties: 094 * 095 * * `displayResource`: a map with the following entries: 096 * - `preferredIpVersion`: `ipv4` or `ipv6` (default: `ipv4`). 097 * Determines the IP addresses uses in the generated 098 * connection file. 099 * * `deleteConnectionFile`: `true` or `false` (default: `true`). 100 * If `true`, the downloaded connection file will be deleted by 101 * the remote viewer when opened. 102 * * `syncPreviewsFor`: a list objects with either property `user` or 103 * `role` and the associated name (default: `[]`). 104 * The remote viewer will synchronize the previews for the specified 105 * users and roles. 106 * 107 */ 108@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.ExcessiveImports", 109 "PMD.CouplingBetweenObjects", "PMD.GodClass", "PMD.TooManyMethods" }) 110public class VmViewer extends FreeMarkerConlet<VmViewer.ViewerModel> { 111 112 private static final String VM_NAME_PROPERTY = "vmName"; 113 private static final String RENDERED 114 = VmViewer.class.getName() + ".rendered"; 115 private static final String PENDING 116 = VmViewer.class.getName() + ".pending"; 117 private static final Set<RenderMode> MODES = RenderMode.asSet( 118 RenderMode.Preview, RenderMode.Edit); 119 private static final Set<RenderMode> MODES_FOR_GENERATED = RenderMode.asSet( 120 RenderMode.Preview, RenderMode.StickyPreview); 121 private final ChannelTracker<String, VmChannel, 122 VmDefinition> channelTracker = new ChannelTracker<>(); 123 private static ObjectMapper objectMapper 124 = new ObjectMapper().registerModule(new JavaTimeModule()); 125 private Class<?> preferredIpVersion = Inet4Address.class; 126 private Set<String> syncUsers = Collections.emptySet(); 127 private Set<String> syncRoles = Collections.emptySet(); 128 private boolean deleteConnectionFile = true; 129 130 /** 131 * The periodically generated update event. 132 */ 133 public static class Update extends Event<Void> { 134 } 135 136 /** 137 * Creates a new component with its channel set to the given channel. 138 * 139 * @param componentChannel the channel that the component's handlers listen 140 * on by default and that {@link Manager#fire(Event, Channel...)} 141 * sends the event to 142 */ 143 public VmViewer(Channel componentChannel) { 144 super(componentChannel); 145 } 146 147 /** 148 * Configure the component. 149 * 150 * @param event the event 151 */ 152 @SuppressWarnings({ "unchecked", "PMD.AvoidDuplicateLiterals" }) 153 @Handler 154 public void onConfigurationUpdate(ConfigurationUpdate event) { 155 event.structured(componentPath()).ifPresent(c -> { 156 try { 157 var dispRes = (Map<String, Object>) c 158 .getOrDefault("displayResource", Collections.emptyMap()); 159 switch ((String) dispRes.getOrDefault("preferredIpVersion", 160 "")) { 161 case "ipv6": 162 preferredIpVersion = Inet6Address.class; 163 break; 164 case "ipv4": 165 default: 166 preferredIpVersion = Inet4Address.class; 167 break; 168 } 169 170 // Delete connection file 171 deleteConnectionFile 172 = Optional.ofNullable(c.get("deleteConnectionFile")) 173 .filter(v -> v instanceof String).map(v -> (String) v) 174 .map(Boolean::parseBoolean).orElse(true); 175 176 // Users or roles for which previews should be synchronized 177 syncUsers = ((List<Map<String, String>>) c.getOrDefault( 178 "syncPreviewsFor", Collections.emptyList())).stream() 179 .map(m -> m.get("user")) 180 .filter(s -> s != null).collect(Collectors.toSet()); 181 logger.finest(() -> "Syncing previews for users: " 182 + syncUsers.toString()); 183 syncRoles = ((List<Map<String, String>>) c.getOrDefault( 184 "syncPreviewsFor", Collections.emptyList())).stream() 185 .map(m -> m.get("role")) 186 .filter(s -> s != null).collect(Collectors.toSet()); 187 logger.finest(() -> "Syncing previews for roles: " 188 + syncRoles.toString()); 189 } catch (ClassCastException e) { 190 logger.config("Malformed configuration: " + e.getMessage()); 191 } 192 }); 193 } 194 195 private boolean syncPreviews(Session session) { 196 return WebConsoleUtils.userFromSession(session) 197 .filter(u -> syncUsers.contains(u.getName())).isPresent() 198 || WebConsoleUtils.rolesFromSession(session).stream() 199 .filter(cr -> syncRoles.contains(cr.getName())).findAny() 200 .isPresent(); 201 } 202 203 /** 204 * On {@link ConsoleReady}, fire the {@link AddConletType}. 205 * 206 * @param event the event 207 * @param channel the channel 208 * @throws TemplateNotFoundException the template not found exception 209 * @throws MalformedTemplateNameException the malformed template name 210 * exception 211 * @throws ParseException the parse exception 212 * @throws IOException Signals that an I/O exception has occurred. 213 */ 214 @Handler 215 public void onConsoleReady(ConsoleReady event, ConsoleConnection channel) 216 throws TemplateNotFoundException, MalformedTemplateNameException, 217 ParseException, IOException { 218 // Add conlet resources to page 219 channel.respond(new AddConletType(type()) 220 .setDisplayNames( 221 localizations(channel.supportedLocales(), "conletName")) 222 .addRenderMode(RenderMode.Preview) 223 .addScript(new ScriptResource().setScriptType("module") 224 .setScriptUri(event.renderSupport().conletResource( 225 type(), "VmViewer-functions.js")))); 226 channel.session().put(RENDERED, new HashSet<>()); 227 } 228 229 /** 230 * On console configured. 231 * 232 * @param event the event 233 * @param connection the console connection 234 * @throws InterruptedException the interrupted exception 235 */ 236 @Handler 237 @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") 238 public void onConsoleConfigured(ConsoleConfigured event, 239 ConsoleConnection connection) throws InterruptedException, 240 IOException { 241 @SuppressWarnings("unchecked") 242 final var rendered = (Set<String>) connection.session().get(RENDERED); 243 connection.session().remove(RENDERED); 244 if (!syncPreviews(connection.session())) { 245 return; 246 } 247 248 boolean foundMissing = false; 249 for (var vmName : accessibleVms(connection)) { 250 if (rendered.contains(vmName)) { 251 continue; 252 } 253 if (!foundMissing) { 254 // Suspending to allow rendering of conlets to be noticed 255 var failSafe = Components.schedule(t -> event.resumeHandling(), 256 Duration.ofSeconds(1)); 257 event.suspendHandling(failSafe::cancel); 258 connection.setAssociated(PENDING, event); 259 foundMissing = true; 260 } 261 fire(new AddConletRequest(event.event().event().renderSupport(), 262 VmViewer.class.getName(), 263 RenderMode.asSet(RenderMode.Preview)) 264 .addProperty(VM_NAME_PROPERTY, vmName), 265 connection); 266 } 267 } 268 269 /** 270 * On console prepared. 271 * 272 * @param event the event 273 * @param connection the connection 274 */ 275 @Handler 276 public void onConsolePrepared(ConsolePrepared event, 277 ConsoleConnection connection) { 278 if (syncPreviews(connection.session())) { 279 connection.respond(new UpdateConletType(type())); 280 } 281 } 282 283 private String storagePath(Session session, String conletId) { 284 return "/" + WebConsoleUtils.userFromSession(session) 285 .map(ConsoleUser::getName).orElse("") 286 + "/" + VmViewer.class.getName() + "/" + conletId; 287 } 288 289 @Override 290 protected Optional<ViewerModel> createNewState(AddConletRequest event, 291 ConsoleConnection connection, String conletId) throws Exception { 292 var model = new ViewerModel(conletId); 293 model.vmName = (String) event.properties().get(VM_NAME_PROPERTY); 294 if (model.vmName != null) { 295 model.setGenerated(true); 296 } 297 String jsonState = objectMapper.writeValueAsString(model); 298 connection.respond(new KeyValueStoreUpdate().update( 299 storagePath(connection.session(), model.getConletId()), jsonState)); 300 return Optional.of(model); 301 } 302 303 @Override 304 protected Optional<ViewerModel> createStateRepresentation(Event<?> event, 305 ConsoleConnection connection, String conletId) throws Exception { 306 var model = new ViewerModel(conletId); 307 String jsonState = objectMapper.writeValueAsString(model); 308 connection.respond(new KeyValueStoreUpdate().update( 309 storagePath(connection.session(), model.getConletId()), jsonState)); 310 return Optional.of(model); 311 } 312 313 @Override 314 @SuppressWarnings("PMD.EmptyCatchBlock") 315 protected Optional<ViewerModel> recreateState(Event<?> event, 316 ConsoleConnection channel, String conletId) throws Exception { 317 KeyValueStoreQuery query = new KeyValueStoreQuery( 318 storagePath(channel.session(), conletId), channel); 319 newEventPipeline().fire(query, channel); 320 try { 321 if (!query.results().isEmpty()) { 322 var json = query.results().get(0).values().stream().findFirst() 323 .get(); 324 ViewerModel model 325 = objectMapper.readValue(json, ViewerModel.class); 326 return Optional.of(model); 327 } 328 } catch (InterruptedException e) { 329 // Means we have no result. 330 } 331 332 // Fall back to creating default state. 333 return createStateRepresentation(event, channel, conletId); 334 } 335 336 @Override 337 @SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops", "unchecked" }) 338 protected Set<RenderMode> doRenderConlet(RenderConletRequestBase<?> event, 339 ConsoleConnection channel, String conletId, ViewerModel model) 340 throws Exception { 341 ResourceBundle resourceBundle = resourceBundle(channel.locale()); 342 Set<RenderMode> renderedAs = EnumSet.noneOf(RenderMode.class); 343 if (event.renderAs().contains(RenderMode.Preview)) { 344 channel.associated(PENDING, Event.class) 345 .ifPresent(e -> { 346 e.resumeHandling(); 347 channel.setAssociated(PENDING, null); 348 }); 349 350 // Remove conlet if definition has been removed 351 if (model.vmName() != null 352 && !channelTracker.associated(model.vmName()).isPresent()) { 353 channel.respond( 354 new DeleteConlet(conletId, Collections.emptySet())); 355 return Collections.emptySet(); 356 } 357 358 // Don't render if user has not at least one permission 359 if (model.vmName() != null 360 && channelTracker.associated(model.vmName()) 361 .map(d -> permissions(d, channel.session()).isEmpty()) 362 .orElse(true)) { 363 return Collections.emptySet(); 364 } 365 366 // Render 367 Template tpl 368 = freemarkerConfig().getTemplate("VmViewer-preview.ftl.html"); 369 channel.respond(new RenderConlet(type(), conletId, 370 processTemplate(event, tpl, 371 fmModel(event, channel, conletId, model))) 372 .setRenderAs( 373 RenderMode.Preview.addModifiers(event.renderAs())) 374 .setSupportedModes(syncPreviews(channel.session()) 375 ? MODES_FOR_GENERATED 376 : MODES)); 377 renderedAs.add(RenderMode.Preview); 378 if (!Strings.isNullOrEmpty(model.vmName())) { 379 Optional.ofNullable(channel.session().get(RENDERED)) 380 .ifPresent(s -> ((Set<String>) s).add(model.vmName())); 381 updateConfig(channel, model); 382 } 383 } 384 if (event.renderAs().contains(RenderMode.Edit)) { 385 Template tpl = freemarkerConfig() 386 .getTemplate("VmViewer-edit.ftl.html"); 387 var fmModel = fmModel(event, channel, conletId, model); 388 fmModel.put("vmNames", accessibleVms(channel)); 389 channel.respond(new OpenModalDialog(type(), conletId, 390 processTemplate(event, tpl, fmModel)) 391 .addOption("cancelable", true) 392 .addOption("okayLabel", 393 resourceBundle.getString("okayLabel"))); 394 } 395 return renderedAs; 396 } 397 398 private List<String> accessibleVms(ConsoleConnection channel) { 399 return channelTracker.associated().stream() 400 .filter(d -> !permissions(d, channel.session()).isEmpty()) 401 .map(d -> d.getMetadata().getName()).sorted().toList(); 402 } 403 404 private Set<Permission> permissions(VmDefinition vmDef, Session session) { 405 var user = WebConsoleUtils.userFromSession(session) 406 .map(ConsoleUser::getName).orElse(null); 407 var roles = WebConsoleUtils.rolesFromSession(session) 408 .stream().map(ConsoleRole::getName).toList(); 409 return vmDef.permissionsFor(user, roles); 410 } 411 412 private void updateConfig(ConsoleConnection channel, ViewerModel model) { 413 channel.respond(new NotifyConletView(type(), 414 model.getConletId(), "updateConfig", model.vmName())); 415 updateVmDef(channel, model); 416 } 417 418 private void updateVmDef(ConsoleConnection channel, ViewerModel model) { 419 if (Strings.isNullOrEmpty(model.vmName())) { 420 return; 421 } 422 channelTracker.value(model.vmName()).ifPresent(item -> { 423 try { 424 var vmDef = item.associated(); 425 var data = Map.of("metadata", 426 Map.of("namespace", vmDef.namespace(), 427 "name", vmDef.name()), 428 "spec", vmDef.spec(), 429 "status", vmDef.getStatus(), 430 "userPermissions", 431 permissions(vmDef, channel.session()).stream() 432 .map(Permission::toString).toList()); 433 channel.respond(new NotifyConletView(type(), 434 model.getConletId(), "updateVmDefinition", data)); 435 } catch (JsonSyntaxException e) { 436 logger.log(Level.SEVERE, e, 437 () -> "Failed to serialize VM definition"); 438 } 439 }); 440 } 441 442 @Override 443 protected void doConletDeleted(ConletDeleted event, 444 ConsoleConnection channel, String conletId, ViewerModel conletState) 445 throws Exception { 446 if (event.renderModes().isEmpty()) { 447 channel.respond(new KeyValueStoreUpdate().delete( 448 storagePath(channel.session(), conletId))); 449 } 450 } 451 452 /** 453 * Track the VM definitions. 454 * 455 * @param event the event 456 * @param channel the channel 457 * @throws IOException 458 */ 459 @Handler(namedChannels = "manager") 460 @SuppressWarnings({ "PMD.ConfusingTernary", "PMD.CognitiveComplexity", 461 "PMD.AvoidInstantiatingObjectsInLoops", "PMD.AvoidDuplicateLiterals", 462 "PMD.ConfusingArgumentToVarargsMethod" }) 463 public void onVmDefChanged(VmDefChanged event, VmChannel channel) 464 throws IOException { 465 var vmDef = event.vmDefinition(); 466 var vmName = vmDef.name(); 467 if (event.type() == K8sObserver.ResponseType.DELETED) { 468 channelTracker.remove(vmName); 469 } else { 470 channelTracker.put(vmName, channel, vmDef); 471 } 472 for (var entry : conletIdsByConsoleConnection().entrySet()) { 473 var connection = entry.getKey(); 474 for (var conletId : entry.getValue()) { 475 var model = stateFromSession(connection.session(), conletId); 476 if (model.isEmpty() 477 || !Objects.areEqual(model.get().vmName(), vmName)) { 478 continue; 479 } 480 if (event.type() == K8sObserver.ResponseType.DELETED) { 481 connection.respond( 482 new DeleteConlet(conletId, Collections.emptySet())); 483 } else { 484 updateVmDef(connection, model.get()); 485 } 486 } 487 } 488 } 489 490 @Override 491 @SuppressWarnings({ "PMD.AvoidDecimalLiteralsInBigDecimalConstructor", 492 "PMD.ConfusingArgumentToVarargsMethod", "PMD.NcssCount", 493 "PMD.AvoidLiteralsInIfCondition" }) 494 protected void doUpdateConletState(NotifyConletModel event, 495 ConsoleConnection channel, ViewerModel model) 496 throws Exception { 497 event.stop(); 498 if ("selectedVm".equals(event.method())) { 499 selectVm(event, channel, model); 500 return; 501 } 502 503 // Handle command for selected VM 504 var both = Optional.ofNullable(model.vmName()) 505 .flatMap(vm -> channelTracker.value(vm)); 506 if (both.isEmpty()) { 507 return; 508 } 509 var vmChannel = both.get().channel(); 510 var vmDef = both.get().associated(); 511 var vmName = vmDef.metadata().getName(); 512 var perms = permissions(vmDef, channel.session()); 513 var resourceBundle = resourceBundle(channel.locale()); 514 switch (event.method()) { 515 case "start": 516 if (perms.contains(Permission.START)) { 517 fire(new ModifyVm(vmName, "state", "Running", vmChannel)); 518 } 519 break; 520 case "stop": 521 if (perms.contains(Permission.STOP)) { 522 fire(new ModifyVm(vmName, "state", "Stopped", vmChannel)); 523 } 524 break; 525 case "reset": 526 if (perms.contains(Permission.RESET)) { 527 confirmReset(event, channel, model, resourceBundle); 528 } 529 break; 530 case "resetConfirmed": 531 if (perms.contains(Permission.RESET)) { 532 fire(new ResetVm(vmName), vmChannel); 533 } 534 break; 535 case "openConsole": 536 if (perms.contains(Permission.ACCESS_CONSOLE)) { 537 var user = WebConsoleUtils.userFromSession(channel.session()) 538 .map(ConsoleUser::getName).orElse(""); 539 var pwQuery 540 = Event.onCompletion(new GetDisplayPassword(vmDef, user), 541 e -> openConsole(vmName, channel, model, 542 e.password().orElse(null))); 543 fire(pwQuery, vmChannel); 544 } 545 break; 546 default:// ignore 547 break; 548 } 549 } 550 551 private void selectVm(NotifyConletModel event, ConsoleConnection channel, 552 ViewerModel model) throws JsonProcessingException { 553 model.setVmName(event.param(0)); 554 String jsonState = objectMapper.writeValueAsString(model); 555 channel.respond(new KeyValueStoreUpdate().update(storagePath( 556 channel.session(), model.getConletId()), jsonState)); 557 updateConfig(channel, model); 558 } 559 560 private void openConsole(String vmName, ConsoleConnection connection, 561 ViewerModel model, String password) { 562 var vmDef = channelTracker.associated(vmName).orElse(null); 563 if (vmDef == null) { 564 return; 565 } 566 var addr = displayIp(vmDef); 567 if (addr.isEmpty()) { 568 logger.severe(() -> "Failed to find display IP for " + vmName); 569 return; 570 } 571 var port = vmDef.<Number> fromVm("display", "spice", "port") 572 .map(Number::longValue); 573 if (port.isEmpty()) { 574 logger.severe(() -> "No port defined for display of " + vmName); 575 return; 576 } 577 StringBuffer data = new StringBuffer(100) 578 .append("[virt-viewer]\ntype=spice\nhost=") 579 .append(addr.get().getHostAddress()).append("\nport=") 580 .append(port.get().toString()) 581 .append('\n'); 582 if (password != null) { 583 data.append("password=").append(password).append('\n'); 584 } 585 vmDef.<String> fromVm("display", "spice", "proxyUrl") 586 .ifPresent(u -> { 587 if (!Strings.isNullOrEmpty(u)) { 588 data.append("proxy=").append(u).append('\n'); 589 } 590 }); 591 if (deleteConnectionFile) { 592 data.append("delete-this-file=1\n"); 593 } 594 connection.respond(new NotifyConletView(type(), 595 model.getConletId(), "openConsole", "application/x-virt-viewer", 596 Base64.getEncoder().encodeToString(data.toString().getBytes()))); 597 } 598 599 private Optional<InetAddress> displayIp(VmDefinition vmDef) { 600 Optional<String> server = vmDef.fromVm("display", "spice", "server"); 601 if (server.isPresent()) { 602 var srv = server.get(); 603 try { 604 var addr = InetAddress.getByName(srv); 605 logger.fine(() -> "Using IP address from CRD for " 606 + vmDef.getMetadata().getName() + ": " + addr); 607 return Optional.of(addr); 608 } catch (UnknownHostException e) { 609 logger.log(Level.SEVERE, e, () -> "Invalid server address " 610 + srv + ": " + e.getMessage()); 611 return Optional.empty(); 612 } 613 } 614 var addrs = Optional.<List<String>> ofNullable(vmDef 615 .extra("nodeAddresses")).orElse(Collections.emptyList()).stream() 616 .map(a -> { 617 try { 618 return InetAddress.getByName(a); 619 } catch (UnknownHostException e) { 620 logger.warning(() -> "Invalid IP address: " + a); 621 return null; 622 } 623 }).filter(a -> a != null).toList(); 624 logger.fine(() -> "Known IP addresses for " 625 + vmDef.name() + ": " + addrs); 626 return addrs.stream() 627 .filter(a -> preferredIpVersion.isAssignableFrom(a.getClass())) 628 .findFirst().or(() -> addrs.stream().findFirst()); 629 } 630 631 private void confirmReset(NotifyConletModel event, 632 ConsoleConnection channel, ViewerModel model, 633 ResourceBundle resourceBundle) throws TemplateNotFoundException, 634 MalformedTemplateNameException, ParseException, IOException { 635 Template tpl = freemarkerConfig() 636 .getTemplate("VmViewer-confirmReset.ftl.html"); 637 channel.respond(new OpenModalDialog(type(), model.getConletId(), 638 processTemplate(event, tpl, 639 fmModel(event, channel, model.getConletId(), model))) 640 .addOption("cancelable", true).addOption("closeLabel", "") 641 .addOption("title", 642 resourceBundle.getString("confirmResetTitle"))); 643 } 644 645 @Override 646 protected boolean doSetLocale(SetLocale event, ConsoleConnection channel, 647 String conletId) throws Exception { 648 return true; 649 } 650 651 /** 652 * The Class VmsModel. 653 */ 654 @SuppressWarnings("PMD.DataClass") 655 public static class ViewerModel extends ConletBaseModel { 656 657 private String vmName; 658 private boolean generated; 659 660 /** 661 * Instantiates a new vms model. 662 * 663 * @param conletId the conlet id 664 */ 665 public ViewerModel(@JsonProperty("conletId") String conletId) { 666 super(conletId); 667 } 668 669 /** 670 * Gets the vm name. 671 * 672 * @return the vmName 673 */ 674 @JsonGetter("vmName") 675 public String vmName() { 676 return vmName; 677 } 678 679 /** 680 * Sets the vm name. 681 * 682 * @param vmName the vmName to set 683 */ 684 public void setVmName(String vmName) { 685 this.vmName = vmName; 686 } 687 688 /** 689 * Checks if is generated. 690 * 691 * @return the generated 692 */ 693 public boolean isGenerated() { 694 return generated; 695 } 696 697 /** 698 * Sets the generated. 699 * 700 * @param generated the generated to set 701 */ 702 public void setGenerated(boolean generated) { 703 this.generated = generated; 704 } 705 706 } 707}