From 761e94b739d72a793bc8e18ed544c8fd831ce2bc Mon Sep 17 00:00:00 2001 From: "Michael N. Lipp" Date: Fri, 17 Jan 2025 11:18:28 +0100 Subject: [PATCH] Independent conlet for pools. --- org.jdrupes.vmoperator.manager/build.gradle | 1 + org.jdrupes.vmoperator.poolaccess/.checkstyle | 10 + .../.eclipse-pmd | 7 + .../.eslintignore | 1 + .../.eslintrc.json | 15 + org.jdrupes.vmoperator.poolaccess/.gitignore | 4 + .../org.eclipse.buildship.core.prefs | 2 + .../org.eclipse.core.resources.prefs | 2 + .../.settings/org.eclipse.core.runtime.prefs | 2 + .../.settings/org.eclipse.jdt.ui.prefs | 63 ++ .../build.gradle | 57 ++ .../package.json | 1 + ...pes.webconsole.base.ConletComponentFactory | 1 + .../poolaccess/PoolAccess-edit.ftl.html | 21 + .../poolaccess/PoolAccess-l10nBundles.ftl.js | 31 + .../poolaccess/PoolAccess-preview.ftl.html | 7 + .../vmoperator/poolaccess/computer-in-use.svg | 90 ++ .../vmoperator/poolaccess/computer-off.svg | 74 ++ .../vmoperator/poolaccess/computer.svg | 74 ++ .../vmoperator/poolaccess/l10n.properties | 3 + .../vmoperator/poolaccess/l10n_de.properties | 9 + .../vmoperator/poolaccess/l10n_en.properties | 0 .../rollup.config.mjs | 35 + .../vmoperator/poolaccess/PoolAccess.java | 848 ++++++++++++++++++ .../poolaccess/PoolAccessFactory.java | 54 ++ .../browser/PoolAccess-functions.ts | 268 ++++++ .../poolaccess/browser/PoolAccess-style.scss | 104 +++ .../poolaccess/browser/l10nBundles-stub.d.ts | 1 + .../vmoperator/poolaccess/package-info.java | 19 + .../tsconfig.json | 23 + settings.gradle | 1 + 31 files changed, 1828 insertions(+) create mode 100644 org.jdrupes.vmoperator.poolaccess/.checkstyle create mode 100644 org.jdrupes.vmoperator.poolaccess/.eclipse-pmd create mode 100644 org.jdrupes.vmoperator.poolaccess/.eslintignore create mode 100644 org.jdrupes.vmoperator.poolaccess/.eslintrc.json create mode 100644 org.jdrupes.vmoperator.poolaccess/.gitignore create mode 100644 org.jdrupes.vmoperator.poolaccess/.settings/org.eclipse.buildship.core.prefs create mode 100644 org.jdrupes.vmoperator.poolaccess/.settings/org.eclipse.core.resources.prefs create mode 100644 org.jdrupes.vmoperator.poolaccess/.settings/org.eclipse.core.runtime.prefs create mode 100644 org.jdrupes.vmoperator.poolaccess/.settings/org.eclipse.jdt.ui.prefs create mode 100644 org.jdrupes.vmoperator.poolaccess/build.gradle create mode 100644 org.jdrupes.vmoperator.poolaccess/package.json create mode 100644 org.jdrupes.vmoperator.poolaccess/resources/META-INF/services/org.jgrapes.webconsole.base.ConletComponentFactory create mode 100644 org.jdrupes.vmoperator.poolaccess/resources/org/jdrupes/vmoperator/poolaccess/PoolAccess-edit.ftl.html create mode 100644 org.jdrupes.vmoperator.poolaccess/resources/org/jdrupes/vmoperator/poolaccess/PoolAccess-l10nBundles.ftl.js create mode 100644 org.jdrupes.vmoperator.poolaccess/resources/org/jdrupes/vmoperator/poolaccess/PoolAccess-preview.ftl.html create mode 100644 org.jdrupes.vmoperator.poolaccess/resources/org/jdrupes/vmoperator/poolaccess/computer-in-use.svg create mode 100644 org.jdrupes.vmoperator.poolaccess/resources/org/jdrupes/vmoperator/poolaccess/computer-off.svg create mode 100644 org.jdrupes.vmoperator.poolaccess/resources/org/jdrupes/vmoperator/poolaccess/computer.svg create mode 100644 org.jdrupes.vmoperator.poolaccess/resources/org/jdrupes/vmoperator/poolaccess/l10n.properties create mode 100644 org.jdrupes.vmoperator.poolaccess/resources/org/jdrupes/vmoperator/poolaccess/l10n_de.properties create mode 100644 org.jdrupes.vmoperator.poolaccess/resources/org/jdrupes/vmoperator/poolaccess/l10n_en.properties create mode 100644 org.jdrupes.vmoperator.poolaccess/rollup.config.mjs create mode 100644 org.jdrupes.vmoperator.poolaccess/src/org/jdrupes/vmoperator/poolaccess/PoolAccess.java create mode 100644 org.jdrupes.vmoperator.poolaccess/src/org/jdrupes/vmoperator/poolaccess/PoolAccessFactory.java create mode 100644 org.jdrupes.vmoperator.poolaccess/src/org/jdrupes/vmoperator/poolaccess/browser/PoolAccess-functions.ts create mode 100644 org.jdrupes.vmoperator.poolaccess/src/org/jdrupes/vmoperator/poolaccess/browser/PoolAccess-style.scss create mode 100644 org.jdrupes.vmoperator.poolaccess/src/org/jdrupes/vmoperator/poolaccess/browser/l10nBundles-stub.d.ts create mode 100644 org.jdrupes.vmoperator.poolaccess/src/org/jdrupes/vmoperator/poolaccess/package-info.java create mode 100644 org.jdrupes.vmoperator.poolaccess/tsconfig.json diff --git a/org.jdrupes.vmoperator.manager/build.gradle b/org.jdrupes.vmoperator.manager/build.gradle index b581971c1..2cb5b6c3c 100644 --- a/org.jdrupes.vmoperator.manager/build.gradle +++ b/org.jdrupes.vmoperator.manager/build.gradle @@ -33,6 +33,7 @@ dependencies { runtimeOnly project(':org.jdrupes.vmoperator.vmmgmt') runtimeOnly project(':org.jdrupes.vmoperator.vmaccess') + runtimeOnly project(':org.jdrupes.vmoperator.poolaccess') } application { diff --git a/org.jdrupes.vmoperator.poolaccess/.checkstyle b/org.jdrupes.vmoperator.poolaccess/.checkstyle new file mode 100644 index 000000000..7f2c604c0 --- /dev/null +++ b/org.jdrupes.vmoperator.poolaccess/.checkstyle @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/org.jdrupes.vmoperator.poolaccess/.eclipse-pmd b/org.jdrupes.vmoperator.poolaccess/.eclipse-pmd new file mode 100644 index 000000000..5d69caac2 --- /dev/null +++ b/org.jdrupes.vmoperator.poolaccess/.eclipse-pmd @@ -0,0 +1,7 @@ + + + + + + + diff --git a/org.jdrupes.vmoperator.poolaccess/.eslintignore b/org.jdrupes.vmoperator.poolaccess/.eslintignore new file mode 100644 index 000000000..139d3ee91 --- /dev/null +++ b/org.jdrupes.vmoperator.poolaccess/.eslintignore @@ -0,0 +1 @@ +rollup.config.mjs diff --git a/org.jdrupes.vmoperator.poolaccess/.eslintrc.json b/org.jdrupes.vmoperator.poolaccess/.eslintrc.json new file mode 100644 index 000000000..e4f80f17a --- /dev/null +++ b/org.jdrupes.vmoperator.poolaccess/.eslintrc.json @@ -0,0 +1,15 @@ +{ + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended" + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { "project": ["./tsconfig.json"] }, + "plugins": [ + "@typescript-eslint" + ], + "rules": { + "constructor-super": "off" + } +} + diff --git a/org.jdrupes.vmoperator.poolaccess/.gitignore b/org.jdrupes.vmoperator.poolaccess/.gitignore new file mode 100644 index 000000000..a53e74c35 --- /dev/null +++ b/org.jdrupes.vmoperator.poolaccess/.gitignore @@ -0,0 +1,4 @@ +/bin/ +/bin_test/ +/generated/ +/build/ diff --git a/org.jdrupes.vmoperator.poolaccess/.settings/org.eclipse.buildship.core.prefs b/org.jdrupes.vmoperator.poolaccess/.settings/org.eclipse.buildship.core.prefs new file mode 100644 index 000000000..b1886adb4 --- /dev/null +++ b/org.jdrupes.vmoperator.poolaccess/.settings/org.eclipse.buildship.core.prefs @@ -0,0 +1,2 @@ +connection.project.dir=.. +eclipse.preferences.version=1 diff --git a/org.jdrupes.vmoperator.poolaccess/.settings/org.eclipse.core.resources.prefs b/org.jdrupes.vmoperator.poolaccess/.settings/org.eclipse.core.resources.prefs new file mode 100644 index 000000000..99f26c020 --- /dev/null +++ b/org.jdrupes.vmoperator.poolaccess/.settings/org.eclipse.core.resources.prefs @@ -0,0 +1,2 @@ +eclipse.preferences.version=1 +encoding/=UTF-8 diff --git a/org.jdrupes.vmoperator.poolaccess/.settings/org.eclipse.core.runtime.prefs b/org.jdrupes.vmoperator.poolaccess/.settings/org.eclipse.core.runtime.prefs new file mode 100644 index 000000000..5a0ad22d2 --- /dev/null +++ b/org.jdrupes.vmoperator.poolaccess/.settings/org.eclipse.core.runtime.prefs @@ -0,0 +1,2 @@ +eclipse.preferences.version=1 +line.separator=\n diff --git a/org.jdrupes.vmoperator.poolaccess/.settings/org.eclipse.jdt.ui.prefs b/org.jdrupes.vmoperator.poolaccess/.settings/org.eclipse.jdt.ui.prefs new file mode 100644 index 000000000..784d01f35 --- /dev/null +++ b/org.jdrupes.vmoperator.poolaccess/.settings/org.eclipse.jdt.ui.prefs @@ -0,0 +1,63 @@ +eclipse.preferences.version=1 +editor_save_participant_org.eclipse.jdt.ui.postsavelistener.cleanup=true +formatter_profile=_JGrapes +formatter_settings_version=13 +sp_cleanup.add_default_serial_version_id=true +sp_cleanup.add_generated_serial_version_id=false +sp_cleanup.add_missing_annotations=true +sp_cleanup.add_missing_deprecated_annotations=true +sp_cleanup.add_missing_methods=false +sp_cleanup.add_missing_nls_tags=false +sp_cleanup.add_missing_override_annotations=true +sp_cleanup.add_missing_override_annotations_interface_methods=true +sp_cleanup.add_serial_version_id=false +sp_cleanup.always_use_blocks=true +sp_cleanup.always_use_parentheses_in_expressions=false +sp_cleanup.always_use_this_for_non_static_field_access=false +sp_cleanup.always_use_this_for_non_static_method_access=false +sp_cleanup.convert_functional_interfaces=false +sp_cleanup.convert_to_enhanced_for_loop=false +sp_cleanup.correct_indentation=false +sp_cleanup.format_source_code=true +sp_cleanup.format_source_code_changes_only=false +sp_cleanup.insert_inferred_type_arguments=false +sp_cleanup.make_local_variable_final=true +sp_cleanup.make_parameters_final=false +sp_cleanup.make_private_fields_final=true +sp_cleanup.make_type_abstract_if_missing_method=false +sp_cleanup.make_variable_declarations_final=false +sp_cleanup.never_use_blocks=false +sp_cleanup.never_use_parentheses_in_expressions=true +sp_cleanup.on_save_use_additional_actions=false +sp_cleanup.organize_imports=false +sp_cleanup.qualify_static_field_accesses_with_declaring_class=false +sp_cleanup.qualify_static_member_accesses_through_instances_with_declaring_class=true +sp_cleanup.qualify_static_member_accesses_through_subtypes_with_declaring_class=true +sp_cleanup.qualify_static_member_accesses_with_declaring_class=false +sp_cleanup.qualify_static_method_accesses_with_declaring_class=false +sp_cleanup.remove_private_constructors=true +sp_cleanup.remove_redundant_type_arguments=false +sp_cleanup.remove_trailing_whitespaces=false +sp_cleanup.remove_trailing_whitespaces_all=true +sp_cleanup.remove_trailing_whitespaces_ignore_empty=false +sp_cleanup.remove_unnecessary_casts=true +sp_cleanup.remove_unnecessary_nls_tags=false +sp_cleanup.remove_unused_imports=false +sp_cleanup.remove_unused_local_variables=false +sp_cleanup.remove_unused_private_fields=true +sp_cleanup.remove_unused_private_members=false +sp_cleanup.remove_unused_private_methods=true +sp_cleanup.remove_unused_private_types=true +sp_cleanup.sort_members=false +sp_cleanup.sort_members_all=false +sp_cleanup.use_anonymous_class_creation=false +sp_cleanup.use_blocks=false +sp_cleanup.use_blocks_only_for_return_and_throw=false +sp_cleanup.use_lambda=true +sp_cleanup.use_parentheses_in_expressions=false +sp_cleanup.use_this_for_non_static_field_access=false +sp_cleanup.use_this_for_non_static_field_access_only_if_necessary=true +sp_cleanup.use_this_for_non_static_method_access=false +sp_cleanup.use_this_for_non_static_method_access_only_if_necessary=true +sp_jautodoc.cleanup.add_header=false +sp_jautodoc.cleanup.replace_header=false diff --git a/org.jdrupes.vmoperator.poolaccess/build.gradle b/org.jdrupes.vmoperator.poolaccess/build.gradle new file mode 100644 index 000000000..606c6cd44 --- /dev/null +++ b/org.jdrupes.vmoperator.poolaccess/build.gradle @@ -0,0 +1,57 @@ +plugins { + id 'org.jdrupes.vmoperator.java-library-conventions' +} + +dependencies { + implementation project(':org.jdrupes.vmoperator.manager.events') + + implementation 'org.jgrapes:org.jgrapes.webconsole.base:[2.1.0,3)' + implementation 'org.jgrapes:org.jgrapes.webconsole.provider.vue:[1,2)' + implementation 'org.jgrapes:org.jgrapes.webconsole.provider.jgwcvuecomponents:[1.2,2)' + implementation 'org.jgrapes:org.jgrapes.webconsole.provider.chartjs:[1.2,2)' + +} + +apply plugin: 'com.github.node-gradle.node' + +node { + download = true +} + +task extractDependencies(type: Copy) { + from configurations.compileClasspath + .findAll{ it.name.contains('.provider.') + || it.name.contains('org.jgrapes.webconsole.base') + } + .collect{ zipTree (it) } + exclude '*.class' + into 'build/unpacked' + duplicatesStrategy 'include' + } + +task compileTs(type: NodeTask) { + dependsOn ':npmInstall' + dependsOn extractDependencies + inputs.dir project.file('src') + inputs.file project.file('tsconfig.json') + inputs.file project.file('rollup.config.mjs') + outputs.dir project.file('build/generated/resources') + script = file("${rootProject.rootDir}/node_modules/rollup/dist/bin/rollup") + args = ["-c"] +} + +sourceSets { + main { + resources { + srcDir project.file('build/generated/resources') + } + } +} + +processResources { + dependsOn compileTs +} + +eclipse { + autoBuildTasks compileTs +} diff --git a/org.jdrupes.vmoperator.poolaccess/package.json b/org.jdrupes.vmoperator.poolaccess/package.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/org.jdrupes.vmoperator.poolaccess/package.json @@ -0,0 +1 @@ +{} diff --git a/org.jdrupes.vmoperator.poolaccess/resources/META-INF/services/org.jgrapes.webconsole.base.ConletComponentFactory b/org.jdrupes.vmoperator.poolaccess/resources/META-INF/services/org.jgrapes.webconsole.base.ConletComponentFactory new file mode 100644 index 000000000..dd5df56cd --- /dev/null +++ b/org.jdrupes.vmoperator.poolaccess/resources/META-INF/services/org.jgrapes.webconsole.base.ConletComponentFactory @@ -0,0 +1 @@ +org.jdrupes.vmoperator.poolaccess.PoolAccessFactory diff --git a/org.jdrupes.vmoperator.poolaccess/resources/org/jdrupes/vmoperator/poolaccess/PoolAccess-edit.ftl.html b/org.jdrupes.vmoperator.poolaccess/resources/org/jdrupes/vmoperator/poolaccess/PoolAccess-edit.ftl.html new file mode 100644 index 000000000..5e5775294 --- /dev/null +++ b/org.jdrupes.vmoperator.poolaccess/resources/org/jdrupes/vmoperator/poolaccess/PoolAccess-edit.ftl.html @@ -0,0 +1,21 @@ +
+
+
+ {{ localize("Select pool") }} +

+ +

+
+
+
diff --git a/org.jdrupes.vmoperator.poolaccess/resources/org/jdrupes/vmoperator/poolaccess/PoolAccess-l10nBundles.ftl.js b/org.jdrupes.vmoperator.poolaccess/resources/org/jdrupes/vmoperator/poolaccess/PoolAccess-l10nBundles.ftl.js new file mode 100644 index 000000000..96928ef9f --- /dev/null +++ b/org.jdrupes.vmoperator.poolaccess/resources/org/jdrupes/vmoperator/poolaccess/PoolAccess-l10nBundles.ftl.js @@ -0,0 +1,31 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +"use strict"; + +const l10nBundles = new Map(); +let entries = null; +// <#list supportedLanguages() as l> +entries = new Map(); +l10nBundles.set("${l.locale.toLanguageTag()}", entries); +// <#list l.l10nBundle.keys as key> +entries.set("${key}", "${l.l10nBundle.getString(key)}"); +// +// + +export default l10nBundles; diff --git a/org.jdrupes.vmoperator.poolaccess/resources/org/jdrupes/vmoperator/poolaccess/PoolAccess-preview.ftl.html b/org.jdrupes.vmoperator.poolaccess/resources/org/jdrupes/vmoperator/poolaccess/PoolAccess-preview.ftl.html new file mode 100644 index 000000000..f34328c0f --- /dev/null +++ b/org.jdrupes.vmoperator.poolaccess/resources/org/jdrupes/vmoperator/poolaccess/PoolAccess-preview.ftl.html @@ -0,0 +1,7 @@ +
+
diff --git a/org.jdrupes.vmoperator.poolaccess/resources/org/jdrupes/vmoperator/poolaccess/computer-in-use.svg b/org.jdrupes.vmoperator.poolaccess/resources/org/jdrupes/vmoperator/poolaccess/computer-in-use.svg new file mode 100644 index 000000000..00e4cc0f2 --- /dev/null +++ b/org.jdrupes.vmoperator.poolaccess/resources/org/jdrupes/vmoperator/poolaccess/computer-in-use.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + diff --git a/org.jdrupes.vmoperator.poolaccess/resources/org/jdrupes/vmoperator/poolaccess/computer-off.svg b/org.jdrupes.vmoperator.poolaccess/resources/org/jdrupes/vmoperator/poolaccess/computer-off.svg new file mode 100644 index 000000000..27c11aef7 --- /dev/null +++ b/org.jdrupes.vmoperator.poolaccess/resources/org/jdrupes/vmoperator/poolaccess/computer-off.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + diff --git a/org.jdrupes.vmoperator.poolaccess/resources/org/jdrupes/vmoperator/poolaccess/computer.svg b/org.jdrupes.vmoperator.poolaccess/resources/org/jdrupes/vmoperator/poolaccess/computer.svg new file mode 100644 index 000000000..f7a6b94a9 --- /dev/null +++ b/org.jdrupes.vmoperator.poolaccess/resources/org/jdrupes/vmoperator/poolaccess/computer.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + diff --git a/org.jdrupes.vmoperator.poolaccess/resources/org/jdrupes/vmoperator/poolaccess/l10n.properties b/org.jdrupes.vmoperator.poolaccess/resources/org/jdrupes/vmoperator/poolaccess/l10n.properties new file mode 100644 index 000000000..c63009dd3 --- /dev/null +++ b/org.jdrupes.vmoperator.poolaccess/resources/org/jdrupes/vmoperator/poolaccess/l10n.properties @@ -0,0 +1,3 @@ +conletName = Pool Access + +okayLabel = Apply and Close diff --git a/org.jdrupes.vmoperator.poolaccess/resources/org/jdrupes/vmoperator/poolaccess/l10n_de.properties b/org.jdrupes.vmoperator.poolaccess/resources/org/jdrupes/vmoperator/poolaccess/l10n_de.properties new file mode 100644 index 000000000..b117a31d5 --- /dev/null +++ b/org.jdrupes.vmoperator.poolaccess/resources/org/jdrupes/vmoperator/poolaccess/l10n_de.properties @@ -0,0 +1,9 @@ +conletName = Pool-Zugriff + +okayLabel = Anwenden und Schließen +Select\ pool = Pool auswählen + +Start\ VM = Pool starten +Stop\ VM = Pool anhalten +Reset\ VM = Pool zurücksetzen +Open\ console = Konsole anzeigen diff --git a/org.jdrupes.vmoperator.poolaccess/resources/org/jdrupes/vmoperator/poolaccess/l10n_en.properties b/org.jdrupes.vmoperator.poolaccess/resources/org/jdrupes/vmoperator/poolaccess/l10n_en.properties new file mode 100644 index 000000000..e69de29bb diff --git a/org.jdrupes.vmoperator.poolaccess/rollup.config.mjs b/org.jdrupes.vmoperator.poolaccess/rollup.config.mjs new file mode 100644 index 000000000..3ad199e41 --- /dev/null +++ b/org.jdrupes.vmoperator.poolaccess/rollup.config.mjs @@ -0,0 +1,35 @@ +import typescript from 'rollup-plugin-typescript2'; +import postcss from 'rollup-plugin-postcss'; + +let packagePath = "org/jdrupes/vmoperator/poolaccess"; +let baseName = "PoolAccess" +let module = "build/generated/resources/" + packagePath + + "/" + baseName + "-functions.js"; + +let pathsMap = { + "aash-plugin": "../../page-resource/aash-vue-components/lib/aash-vue-components.js", + "jgconsole": "../../console-base-resource/jgconsole.js", + "jgwc": "../../page-resource/jgwc-vue-components/jgwc-components.js", + "l10nBundles": "./" + baseName + "-l10nBundles.ftl.js", + "vue": "../../page-resource/vue/vue.esm-browser.js" +} + +export default { + external: ['aash-plugin', 'jgconsole', 'jgwc', 'l10nBundles', 'vue', 'chartjs'], + input: "src/" + packagePath + "/browser/" + baseName + "-functions.ts", + output: [ + { + format: "esm", + file: module, + sourcemap: true, + sourcemapPathTransform: (relativeSourcePath, _sourcemapPath) => { + return relativeSourcePath.replace(/^([^/]*\/){12}/, "./"); + }, + paths: pathsMap + } + ], + plugins: [ + typescript(), + postcss() + ] +}; diff --git a/org.jdrupes.vmoperator.poolaccess/src/org/jdrupes/vmoperator/poolaccess/PoolAccess.java b/org.jdrupes.vmoperator.poolaccess/src/org/jdrupes/vmoperator/poolaccess/PoolAccess.java new file mode 100644 index 000000000..028ef0b0b --- /dev/null +++ b/org.jdrupes.vmoperator.poolaccess/src/org/jdrupes/vmoperator/poolaccess/PoolAccess.java @@ -0,0 +1,848 @@ +/* + * VM-Operator + * Copyright (C) 2023,2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.poolaccess; + +import com.fasterxml.jackson.annotation.JsonGetter; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.google.gson.JsonSyntaxException; +import freemarker.core.ParseException; +import freemarker.template.MalformedTemplateNameException; +import freemarker.template.Template; +import freemarker.template.TemplateNotFoundException; +import io.kubernetes.client.util.Strings; +import java.io.IOException; +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.time.Duration; +import java.util.Base64; +import java.util.Collections; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.ResourceBundle; +import java.util.Set; +import java.util.logging.Level; +import java.util.stream.Collectors; +import org.bouncycastle.util.Objects; +import org.jdrupes.vmoperator.common.K8sObserver; +import org.jdrupes.vmoperator.common.VmDefinition; +import org.jdrupes.vmoperator.common.VmDefinition.Permission; +import org.jdrupes.vmoperator.common.VmPool; +import org.jdrupes.vmoperator.manager.events.ChannelTracker; +import org.jdrupes.vmoperator.manager.events.GetDisplayPassword; +import org.jdrupes.vmoperator.manager.events.ModifyVm; +import org.jdrupes.vmoperator.manager.events.ResetVm; +import org.jdrupes.vmoperator.manager.events.VmChannel; +import org.jdrupes.vmoperator.manager.events.VmDefChanged; +import org.jdrupes.vmoperator.manager.events.VmPoolChanged; +import org.jgrapes.core.Channel; +import org.jgrapes.core.Components; +import org.jgrapes.core.Event; +import org.jgrapes.core.Manager; +import org.jgrapes.core.annotation.Handler; +import org.jgrapes.http.Session; +import org.jgrapes.util.events.ConfigurationUpdate; +import org.jgrapes.util.events.KeyValueStoreQuery; +import org.jgrapes.util.events.KeyValueStoreUpdate; +import org.jgrapes.webconsole.base.Conlet.RenderMode; +import org.jgrapes.webconsole.base.ConletBaseModel; +import org.jgrapes.webconsole.base.ConsoleConnection; +import org.jgrapes.webconsole.base.ConsoleRole; +import org.jgrapes.webconsole.base.ConsoleUser; +import org.jgrapes.webconsole.base.WebConsoleUtils; +import org.jgrapes.webconsole.base.events.AddConletRequest; +import org.jgrapes.webconsole.base.events.AddConletType; +import org.jgrapes.webconsole.base.events.AddPageResources.ScriptResource; +import org.jgrapes.webconsole.base.events.ConletDeleted; +import org.jgrapes.webconsole.base.events.ConsoleConfigured; +import org.jgrapes.webconsole.base.events.ConsolePrepared; +import org.jgrapes.webconsole.base.events.ConsoleReady; +import org.jgrapes.webconsole.base.events.DeleteConlet; +import org.jgrapes.webconsole.base.events.NotifyConletModel; +import org.jgrapes.webconsole.base.events.NotifyConletView; +import org.jgrapes.webconsole.base.events.OpenModalDialog; +import org.jgrapes.webconsole.base.events.RenderConlet; +import org.jgrapes.webconsole.base.events.RenderConletRequestBase; +import org.jgrapes.webconsole.base.events.SetLocale; +import org.jgrapes.webconsole.base.events.UpdateConletType; +import org.jgrapes.webconsole.base.freemarker.FreeMarkerConlet; + +/** + * The Class {@link PoolAccess}. The component supports the following + * configuration properties: + * + * * `displayResource`: a map with the following entries: + * - `preferredIpVersion`: `ipv4` or `ipv6` (default: `ipv4`). + * Determines the IP addresses uses in the generated + * connection file. + * * `deleteConnectionFile`: `true` or `false` (default: `true`). + * If `true`, the downloaded connection file will be deleted by + * the remote viewer when opened. + * * `syncPreviewsFor`: a list objects with either property `user` or + * `role` and the associated name (default: `[]`). + * The remote viewer will synchronize the previews for the specified + * users and roles. + * + */ +@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.ExcessiveImports", + "PMD.CouplingBetweenObjects", "PMD.GodClass", "PMD.TooManyMethods", + "PMD.CyclomaticComplexity" }) +public class PoolAccess extends FreeMarkerConlet { + + private static final String VM_NAME_PROPERTY = "vmName"; + private static final String RENDERED + = PoolAccess.class.getName() + ".rendered"; + private static final String PENDING + = PoolAccess.class.getName() + ".pending"; + private static final Set MODES = RenderMode.asSet( + RenderMode.Preview, RenderMode.Edit); + private static final Set MODES_FOR_GENERATED = RenderMode.asSet( + RenderMode.Preview, RenderMode.StickyPreview); + private final ChannelTracker channelTracker = new ChannelTracker<>(); + private static ObjectMapper objectMapper + = new ObjectMapper().registerModule(new JavaTimeModule()); + private Class preferredIpVersion = Inet4Address.class; + private Set syncUsers = Collections.emptySet(); + private Set syncRoles = Collections.emptySet(); + private boolean deleteConnectionFile = true; + @SuppressWarnings("PMD.UseConcurrentHashMap") + private final Map vmPools = new HashMap<>(); + + /** + * The periodically generated update event. + */ + public static class Update extends Event { + } + + /** + * Creates a new component with its channel set to the given channel. + * + * @param componentChannel the channel that the component's handlers listen + * on by default and that {@link Manager#fire(Event, Channel...)} + * sends the event to + */ + public PoolAccess(Channel componentChannel) { + super(componentChannel); + } + + /** + * Configure the component. + * + * @param event the event + */ + @SuppressWarnings({ "unchecked", "PMD.AvoidDuplicateLiterals" }) + @Handler + public void onConfigurationUpdate(ConfigurationUpdate event) { + event.structured(componentPath()).ifPresent(c -> { + try { + var dispRes = (Map) c + .getOrDefault("displayResource", + Collections.emptyMap()); + switch ((String) dispRes.getOrDefault("preferredIpVersion", + "")) { + case "ipv6": + preferredIpVersion = Inet6Address.class; + break; + case "ipv4": + default: + preferredIpVersion = Inet4Address.class; + break; + } + + // Delete connection file + deleteConnectionFile + = Optional.ofNullable(c.get("deleteConnectionFile")) + .filter(v -> v instanceof String) + .map(v -> (String) v) + .map(Boolean::parseBoolean).orElse(true); + + // Users or roles for which previews should be synchronized + syncUsers = ((List>) c.getOrDefault( + "syncPreviewsFor", Collections.emptyList())).stream() + .map(m -> m.get("user")) + .filter(s -> s != null).collect(Collectors.toSet()); + logger.finest(() -> "Syncing previews for users: " + + syncUsers.toString()); + syncRoles = ((List>) c.getOrDefault( + "syncPreviewsFor", Collections.emptyList())).stream() + .map(m -> m.get("role")) + .filter(s -> s != null).collect(Collectors.toSet()); + logger.finest(() -> "Syncing previews for roles: " + + syncRoles.toString()); + } catch (ClassCastException e) { + logger.config("Malformed configuration: " + e.getMessage()); + } + }); + } + + private boolean syncPreviews(Session session) { + return WebConsoleUtils.userFromSession(session) + .filter(u -> syncUsers.contains(u.getName())).isPresent() + || WebConsoleUtils.rolesFromSession(session).stream() + .filter(cr -> syncRoles.contains(cr.getName())).findAny() + .isPresent(); + } + + /** + * On {@link ConsoleReady}, fire the {@link AddConletType}. + * + * @param event the event + * @param channel the channel + * @throws TemplateNotFoundException the template not found exception + * @throws MalformedTemplateNameException the malformed template name + * exception + * @throws ParseException the parse exception + * @throws IOException Signals that an I/O exception has occurred. + */ + @Handler + public void onConsoleReady(ConsoleReady event, ConsoleConnection channel) + throws TemplateNotFoundException, MalformedTemplateNameException, + ParseException, IOException { + // Add conlet resources to page + channel.respond(new AddConletType(type()) + .setDisplayNames( + localizations(channel.supportedLocales(), "conletName")) + .addRenderMode(RenderMode.Preview) + .addScript(new ScriptResource().setScriptType("module") + .setScriptUri(event.renderSupport().conletResource( + type(), "PoolAccess-functions.js")))); + channel.session().put(RENDERED, new HashSet<>()); + } + + /** + * On console configured. + * + * @param event the event + * @param connection the console connection + * @throws InterruptedException the interrupted exception + */ + @Handler + public void onConsoleConfigured(ConsoleConfigured event, + ConsoleConnection connection) throws InterruptedException, + IOException { + @SuppressWarnings("unchecked") + final var rendered + = (Set) connection.session().get(RENDERED); + connection.session().remove(RENDERED); + if (!syncPreviews(connection.session())) { + return; + } + + addMissingVms(event, connection, rendered); + } + + @SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops", + "PMD.AvoidDuplicateLiterals" }) + private void addMissingVms(ConsoleConfigured event, + ConsoleConnection connection, final Set rendered) { + boolean foundMissing = false; + for (var vmName : accessibleVms(connection)) { + if (rendered.stream() + .anyMatch(r -> r.type() == ResourceModel.Type.VM + && r.name().equals(vmName))) { + continue; + } + if (!foundMissing) { + // Suspending to allow rendering of conlets to be noticed + var failSafe = Components.schedule(t -> event.resumeHandling(), + Duration.ofSeconds(1)); + event.suspendHandling(failSafe::cancel); + connection.setAssociated(PENDING, event); + foundMissing = true; + } + fire(new AddConletRequest(event.event().event().renderSupport(), + PoolAccess.class.getName(), + RenderMode.asSet(RenderMode.Preview)) + .addProperty(VM_NAME_PROPERTY, vmName), + connection); + } + } + + /** + * On console prepared. + * + * @param event the event + * @param connection the connection + */ + @Handler + public void onConsolePrepared(ConsolePrepared event, + ConsoleConnection connection) { + if (syncPreviews(connection.session())) { + connection.respond(new UpdateConletType(type())); + } + } + + private String storagePath(Session session, String conletId) { + return "/" + WebConsoleUtils.userFromSession(session) + .map(ConsoleUser::getName).orElse("") + + "/" + PoolAccess.class.getName() + "/" + conletId; + } + + @Override + protected Optional createNewState(AddConletRequest event, + ConsoleConnection connection, String conletId) throws Exception { + var model = new ResourceModel(conletId); + model.setType(ResourceModel.Type.VM); + model + .setName((String) event.properties().get(VM_NAME_PROPERTY)); + String jsonState = objectMapper.writeValueAsString(model); + connection.respond(new KeyValueStoreUpdate().update( + storagePath(connection.session(), model.getConletId()), jsonState)); + return Optional.of(model); + } + + @Override + protected Optional createStateRepresentation(Event event, + ConsoleConnection connection, String conletId) throws Exception { + var model = new ResourceModel(conletId); + String jsonState = objectMapper.writeValueAsString(model); + connection.respond(new KeyValueStoreUpdate().update( + storagePath(connection.session(), model.getConletId()), jsonState)); + return Optional.of(model); + } + + @Override + @SuppressWarnings("PMD.EmptyCatchBlock") + protected Optional recreateState(Event event, + ConsoleConnection channel, String conletId) throws Exception { + KeyValueStoreQuery query = new KeyValueStoreQuery( + storagePath(channel.session(), conletId), channel); + newEventPipeline().fire(query, channel); + try { + if (!query.results().isEmpty()) { + var json = query.results().get(0).values().stream().findFirst() + .get(); + ResourceModel model + = objectMapper.readValue(json, ResourceModel.class); + return Optional.of(model); + } + } catch (InterruptedException e) { + // Means we have no result. + } + + // Fall back to creating default state. + return createStateRepresentation(event, channel, conletId); + } + + @Override + @SuppressWarnings({ "PMD.AvoidInstantiatingObjectsInLoops" }) + protected Set doRenderConlet(RenderConletRequestBase event, + ConsoleConnection channel, String conletId, ResourceModel model) + throws Exception { + if (event.renderAs().contains(RenderMode.Preview)) { + return renderPreview(event, channel, conletId, model); + } + + // Render edit + ResourceBundle resourceBundle = resourceBundle(channel.locale()); + Set renderedAs = EnumSet.noneOf(RenderMode.class); + if (event.renderAs().contains(RenderMode.Edit)) { + Template tpl = freemarkerConfig() + .getTemplate("PoolAccess-edit.ftl.html"); + var fmModel = fmModel(event, channel, conletId, model); + fmModel.put("vmNames", accessibleVms(channel)); + fmModel.put("poolNames", accessiblePools(channel)); + channel.respond(new OpenModalDialog(type(), conletId, + processTemplate(event, tpl, fmModel)) + .addOption("cancelable", true) + .addOption("okayLabel", + resourceBundle.getString("okayLabel"))); + } + return renderedAs; + } + + @SuppressWarnings("unchecked") + private Set renderPreview(RenderConletRequestBase event, + ConsoleConnection channel, String conletId, ResourceModel model) + throws TemplateNotFoundException, MalformedTemplateNameException, + ParseException, IOException { + channel.associated(PENDING, Event.class) + .ifPresent(e -> { + e.resumeHandling(); + channel.setAssociated(PENDING, null); + }); + + if (model.type() == ResourceModel.Type.VM && model.name() != null) { + // Remove conlet if VM definition has been removed + // or user has not at least one permission + Optional vmDef + = channelTracker.associated(model.name()); + if (vmDef.isEmpty() + || vmPermissions(vmDef.get(), channel.session()).isEmpty()) { + channel.respond( + new DeleteConlet(conletId, Collections.emptySet())); + return Collections.emptySet(); + } + } + + if (model.type() == ResourceModel.Type.POOL && model.name() != null) { + // Remove conlet if pool definition has been removed + // or user has not at least one permission + VmPool pool = vmPools.get(model.name()); + if (pool == null + || poolPermissions(pool, channel.session()).isEmpty()) { + channel.respond( + new DeleteConlet(conletId, Collections.emptySet())); + return Collections.emptySet(); + } + } + + // Render + Template tpl + = freemarkerConfig().getTemplate("PoolAccess-preview.ftl.html"); + channel.respond(new RenderConlet(type(), conletId, + processTemplate(event, tpl, + fmModel(event, channel, conletId, model))) + .setRenderAs( + RenderMode.Preview.addModifiers(event.renderAs())) + .setSupportedModes(syncPreviews(channel.session()) + ? MODES_FOR_GENERATED + : MODES)); + if (!Strings.isNullOrEmpty(model.name())) { + Optional.ofNullable(channel.session().get(RENDERED)) + .ifPresent(s -> ((Set) s).add(model)); + updateConfig(channel, model); + } + return EnumSet.of(RenderMode.Preview); + } + + private List accessibleVms(ConsoleConnection channel) { + return channelTracker.associated().stream() + .filter(d -> !vmPermissions(d, channel.session()).isEmpty()) + .map(d -> d.getMetadata().getName()).sorted().toList(); + } + + private Set vmPermissions(VmDefinition vmDef, + Session session) { + var user = WebConsoleUtils.userFromSession(session) + .map(ConsoleUser::getName).orElse(null); + var roles = WebConsoleUtils.rolesFromSession(session) + .stream().map(ConsoleRole::getName).toList(); + return vmDef.permissionsFor(user, roles); + } + + private List accessiblePools(ConsoleConnection channel) { + return vmPools.values().stream() + .filter(d -> !poolPermissions(d, channel.session()).isEmpty()) + .map(d -> d.name()).sorted().toList(); + } + + private Set poolPermissions(VmPool pool, + Session session) { + var user = WebConsoleUtils.userFromSession(session) + .map(ConsoleUser::getName).orElse(null); + var roles = WebConsoleUtils.rolesFromSession(session) + .stream().map(ConsoleRole::getName).toList(); + return pool.permissionsFor(user, roles); + } + + private void updateConfig(ConsoleConnection channel, ResourceModel model) { + channel.respond(new NotifyConletView(type(), + model.getConletId(), "updateConfig", model.type(), model.name())); + updateVmDef(channel, model); + } + + private void updateVmDef(ConsoleConnection channel, ResourceModel model) { + if (Strings.isNullOrEmpty(model.name())) { + return; + } + channelTracker.value(model.name()).ifPresent(item -> { + try { + var vmDef = item.associated(); + var data = Map.of("metadata", + Map.of("namespace", vmDef.namespace(), + "name", vmDef.name()), + "spec", vmDef.spec(), + "status", vmDef.getStatus(), + "userPermissions", + vmPermissions(vmDef, channel.session()).stream() + .map(VmDefinition.Permission::toString).toList()); + channel.respond(new NotifyConletView(type(), + model.getConletId(), "updateVmDefinition", data)); + } catch (JsonSyntaxException e) { + logger.log(Level.SEVERE, e, + () -> "Failed to serialize VM definition"); + } + }); + } + + @Override + protected void doConletDeleted(ConletDeleted event, + ConsoleConnection channel, String conletId, + ResourceModel conletState) + throws Exception { + if (event.renderModes().isEmpty()) { + channel.respond(new KeyValueStoreUpdate().delete( + storagePath(channel.session(), conletId))); + } + } + + /** + * Track the VM definitions. + * + * @param event the event + * @param channel the channel + * @throws IOException + */ + @Handler(namedChannels = "manager") + @SuppressWarnings({ "PMD.ConfusingTernary", "PMD.CognitiveComplexity", + "PMD.AvoidInstantiatingObjectsInLoops", "PMD.AvoidDuplicateLiterals", + "PMD.ConfusingArgumentToVarargsMethod" }) + public void onVmDefChanged(VmDefChanged event, VmChannel channel) + throws IOException { + var vmDef = event.vmDefinition(); + var vmName = vmDef.name(); + if (event.type() == K8sObserver.ResponseType.DELETED) { + channelTracker.remove(vmName); + } else { + channelTracker.put(vmName, channel, vmDef); + } + + // Update known conlets + for (var entry : conletIdsByConsoleConnection().entrySet()) { + var connection = entry.getKey(); + for (var conletId : entry.getValue()) { + var model = stateFromSession(connection.session(), conletId); + if (model.isEmpty() + || model.get().type() != ResourceModel.Type.VM + || !Objects.areEqual(model.get().name(), vmName)) { + continue; + } + if (event.type() == K8sObserver.ResponseType.DELETED + || vmPermissions(vmDef, connection.session()).isEmpty()) { + connection.respond( + new DeleteConlet(conletId, Collections.emptySet())); + } else { + updateVmDef(connection, model.get()); + } + } + } + } + + /** + * On vm pool changed. + * + * @param event the event + * @param channel the channel + */ + @Handler(namedChannels = "manager") + @SuppressWarnings("PMD.AvoidInstantiatingObjectsInLoops") + public void onVmPoolChanged(VmPoolChanged event) { + var poolName = event.vmPool().name(); + if (event.deleted()) { + vmPools.remove(poolName); + } else { + vmPools.put(poolName, event.vmPool()); + } + + // Update known conlets + for (var entry : conletIdsByConsoleConnection().entrySet()) { + var connection = entry.getKey(); + for (var conletId : entry.getValue()) { + var model = stateFromSession(connection.session(), conletId); + if (model.isEmpty() + || model.get().type() != ResourceModel.Type.POOL + || !Objects.areEqual(model.get().name(), poolName)) { + continue; + } + if (event.deleted() + || poolPermissions(event.vmPool(), connection.session()) + .isEmpty()) { + connection.respond( + new DeleteConlet(conletId, Collections.emptySet())); + } + } + } + } + + @Override + @SuppressWarnings({ "PMD.AvoidDecimalLiteralsInBigDecimalConstructor", + "PMD.ConfusingArgumentToVarargsMethod", "PMD.NcssCount", + "PMD.AvoidLiteralsInIfCondition" }) + protected void doUpdateConletState(NotifyConletModel event, + ConsoleConnection channel, ResourceModel model) + throws Exception { + event.stop(); + if ("selectedResource".equals(event.method())) { + selectResource(event, channel, model); + return; + } + + // Handle command for selected VM + var both = Optional.ofNullable(model.name()) + .flatMap(vm -> channelTracker.value(vm)); + if (both.isEmpty()) { + return; + } + var vmChannel = both.get().channel(); + var vmDef = both.get().associated(); + var vmName = vmDef.metadata().getName(); + var perms = vmPermissions(vmDef, channel.session()); + var resourceBundle = resourceBundle(channel.locale()); + switch (event.method()) { + case "start": + if (perms.contains(VmDefinition.Permission.START)) { + fire(new ModifyVm(vmName, "state", "Running", vmChannel)); + } + break; + case "stop": + if (perms.contains(VmDefinition.Permission.STOP)) { + fire(new ModifyVm(vmName, "state", "Stopped", vmChannel)); + } + break; + case "reset": + if (perms.contains(VmDefinition.Permission.RESET)) { + confirmReset(event, channel, model, resourceBundle); + } + break; + case "resetConfirmed": + if (perms.contains(VmDefinition.Permission.RESET)) { + fire(new ResetVm(vmName), vmChannel); + } + break; + case "openConsole": + if (perms.contains(VmDefinition.Permission.ACCESS_CONSOLE)) { + var user = WebConsoleUtils.userFromSession(channel.session()) + .map(ConsoleUser::getName).orElse(""); + var pwQuery + = Event.onCompletion(new GetDisplayPassword(vmDef, user), + e -> openConsole(vmName, channel, model, + e.password().orElse(null))); + fire(pwQuery, vmChannel); + } + break; + default:// ignore + break; + } + } + + @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", + "PMD.UseLocaleWithCaseConversions" }) + private void selectResource(NotifyConletModel event, + ConsoleConnection channel, ResourceModel model) + throws JsonProcessingException { + try { + model.setType(ResourceModel.Type + .valueOf(event. param(0).toUpperCase())); + model.setName(event.param(1)); + String jsonState = objectMapper.writeValueAsString(model); + channel.respond(new KeyValueStoreUpdate().update(storagePath( + channel.session(), model.getConletId()), jsonState)); + updateConfig(channel, model); + } catch (IllegalArgumentException e) { + logger.warning(() -> "Invalid resource type: " + e.getMessage()); + } + } + + private void openConsole(String vmName, ConsoleConnection connection, + ResourceModel model, String password) { + var vmDef = channelTracker.associated(vmName).orElse(null); + if (vmDef == null) { + return; + } + var addr = displayIp(vmDef); + if (addr.isEmpty()) { + logger.severe(() -> "Failed to find display IP for " + vmName); + return; + } + var port = vmDef. fromVm("display", "spice", "port") + .map(Number::longValue); + if (port.isEmpty()) { + logger.severe(() -> "No port defined for display of " + vmName); + return; + } + StringBuffer data = new StringBuffer(100) + .append("[virt-viewer]\ntype=spice\nhost=") + .append(addr.get().getHostAddress()).append("\nport=") + .append(port.get().toString()) + .append('\n'); + if (password != null) { + data.append("password=").append(password).append('\n'); + } + vmDef. fromVm("display", "spice", "proxyUrl") + .ifPresent(u -> { + if (!Strings.isNullOrEmpty(u)) { + data.append("proxy=").append(u).append('\n'); + } + }); + if (deleteConnectionFile) { + data.append("delete-this-file=1\n"); + } + connection.respond(new NotifyConletView(type(), + model.getConletId(), "openConsole", "application/x-virt-viewer", + Base64.getEncoder().encodeToString(data.toString().getBytes()))); + } + + private Optional displayIp(VmDefinition vmDef) { + Optional server = vmDef.fromVm("display", "spice", "server"); + if (server.isPresent()) { + var srv = server.get(); + try { + var addr = InetAddress.getByName(srv); + logger.fine(() -> "Using IP address from CRD for " + + vmDef.getMetadata().getName() + ": " + addr); + return Optional.of(addr); + } catch (UnknownHostException e) { + logger.log(Level.SEVERE, e, () -> "Invalid server address " + + srv + ": " + e.getMessage()); + return Optional.empty(); + } + } + var addrs = Optional.> ofNullable(vmDef + .extra("nodeAddresses")).orElse(Collections.emptyList()).stream() + .map(a -> { + try { + return InetAddress.getByName(a); + } catch (UnknownHostException e) { + logger.warning(() -> "Invalid IP address: " + a); + return null; + } + }).filter(a -> a != null).toList(); + logger.fine(() -> "Known IP addresses for " + + vmDef.name() + ": " + addrs); + return addrs.stream() + .filter(a -> preferredIpVersion.isAssignableFrom(a.getClass())) + .findFirst().or(() -> addrs.stream().findFirst()); + } + + private void confirmReset(NotifyConletModel event, + ConsoleConnection channel, ResourceModel model, + ResourceBundle resourceBundle) throws TemplateNotFoundException, + MalformedTemplateNameException, ParseException, IOException { + Template tpl = freemarkerConfig() + .getTemplate("PoolAccess-confirmReset.ftl.html"); + channel.respond(new OpenModalDialog(type(), model.getConletId(), + processTemplate(event, tpl, + fmModel(event, channel, model.getConletId(), model))) + .addOption("cancelable", true).addOption("closeLabel", "") + .addOption("title", + resourceBundle.getString("confirmResetTitle"))); + } + + @Override + protected boolean doSetLocale(SetLocale event, ConsoleConnection channel, + String conletId) throws Exception { + return true; + } + + /** + * The Class AccessModel. + */ + @SuppressWarnings("PMD.DataClass") + public static class ResourceModel extends ConletBaseModel { + + /** + * The Enum ResourceType. + */ + @SuppressWarnings("PMD.ShortVariable") + public enum Type { + VM, POOL + } + + private Type type; + private String name; + + /** + * Instantiates a new resource model. + * + * @param conletId the conlet id + */ + public ResourceModel(@JsonProperty("conletId") String conletId) { + super(conletId); + } + + /** + * Gets the resource name. + * + * @return the string + */ + @JsonGetter("name") + public String name() { + return name; + } + + /** + * Sets the name. + * + * @param name the resource name to set + */ + public void setName(String name) { + this.name = name; + } + + /** + * @return the resourceType + */ + @JsonGetter("type") + public Type type() { + return type; + } + + /** + * Sets the type. + * + * @param type the resource type to set + */ + public void setType(Type type) { + this.type = type; + } + + @Override + public int hashCode() { + final int prime = 31; + int result = super.hashCode(); + result = prime * result + java.util.Objects.hash(name, type); + return result; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!super.equals(obj)) { + return false; + } + if (getClass() != obj.getClass()) { + return false; + } + ResourceModel other = (ResourceModel) obj; + return java.util.Objects.equals(name, other.name) + && type == other.type; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(50); + builder.append("AccessModel [resourceType=").append(type) + .append(", resourceName=").append(name).append(']'); + return builder.toString(); + } + + } +} diff --git a/org.jdrupes.vmoperator.poolaccess/src/org/jdrupes/vmoperator/poolaccess/PoolAccessFactory.java b/org.jdrupes.vmoperator.poolaccess/src/org/jdrupes/vmoperator/poolaccess/PoolAccessFactory.java new file mode 100644 index 000000000..26cb0f435 --- /dev/null +++ b/org.jdrupes.vmoperator.poolaccess/src/org/jdrupes/vmoperator/poolaccess/PoolAccessFactory.java @@ -0,0 +1,54 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.poolaccess; + +import java.util.Map; +import java.util.Optional; +import org.jgrapes.core.Channel; +import org.jgrapes.core.ComponentType; +import org.jgrapes.webconsole.base.ConletComponentFactory; + +/** + * The factory service for {@link PoolAccess}s. + */ +public class PoolAccessFactory implements ConletComponentFactory { + + /* + * (non-Javadoc) + * + * @see org.jgrapes.core.ComponentFactory#componentType() + */ + @Override + public Class componentType() { + return PoolAccess.class; + } + + /* + * (non-Javadoc) + * + * @see org.jgrapes.core.ComponentFactory#create(org.jgrapes.core.Channel, + * java.util.Map) + */ + @Override + public Optional create(Channel componentChannel, + Map properties) { + return Optional.of(new PoolAccess(componentChannel)); + } + +} diff --git a/org.jdrupes.vmoperator.poolaccess/src/org/jdrupes/vmoperator/poolaccess/browser/PoolAccess-functions.ts b/org.jdrupes.vmoperator.poolaccess/src/org/jdrupes/vmoperator/poolaccess/browser/PoolAccess-functions.ts new file mode 100644 index 000000000..57eebfe60 --- /dev/null +++ b/org.jdrupes.vmoperator.poolaccess/src/org/jdrupes/vmoperator/poolaccess/browser/PoolAccess-functions.ts @@ -0,0 +1,268 @@ +/* + * VM-Operator + * Copyright (C) 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { + reactive, ref, createApp, computed, watch +} from "vue"; +import JGConsole from "jgconsole"; +import JgwcPlugin, { JGWC } from "jgwc"; +import { provideApi, getApi } from "aash-plugin"; +import l10nBundles from "l10nBundles"; + +import "./PoolAccess-style.scss"; + +// For global access +declare global { + interface Window { + orgJDrupesVmOperatorPoolAccess: { + initPreview?: (previewDom: HTMLElement, isUpdate: boolean) => void, + initEdit?: (viewDom: HTMLElement, isUpdate: boolean) => void, + applyEdit?: (viewDom: HTMLElement, apply: boolean) => void, + confirmReset?: (conletType: string, conletId: string) => void + } + } +} + +window.orgJDrupesVmOperatorPoolAccess = {}; + +interface Api { + /* eslint-disable @typescript-eslint/no-explicit-any */ + vmName: string; + vmDefinition: any; + poolName: string; +} + +const localize = (key: string) => { + return JGConsole.localize( + l10nBundles, JGWC.lang(), key); +}; + +window.orgJDrupesVmOperatorPoolAccess.initPreview = (previewDom: HTMLElement, + _isUpdate: boolean) => { + const app = createApp({ + setup(_props: object) { + const conletId = (previewDom.closest( + "[data-conlet-id]")!).dataset["conletId"]!; + const resourceBase = (previewDom.closest( + "*[data-conlet-resource-base]")!).dataset.conletResourceBase; + + const previewApi: Api = reactive({ + vmName: "", + vmDefinition: {}, + poolName: "" + }); + const configured = computed(() => previewApi.vmDefinition.spec); + const startable = computed(() => previewApi.vmDefinition.spec && + previewApi.vmDefinition.spec.vm.state !== 'Running' + && !previewApi.vmDefinition.running); + const stoppable = computed(() => previewApi.vmDefinition.spec && + previewApi.vmDefinition.spec.vm.state !== 'Stopped' + && previewApi.vmDefinition.running); + const running = computed(() => previewApi.vmDefinition.running); + const inUse = computed(() => previewApi.vmDefinition.usedBy != ''); + const permissions = computed(() => previewApi.vmDefinition.spec + ? previewApi.vmDefinition.userPermissions : []); + + watch(previewApi, (api: Api) => { + const name = api.vmName || api.poolName; + if (name !== "") { + JGConsole.instance.updateConletTitle(conletId, name); + } + }); + + provideApi(previewDom, previewApi); + + const vmAction = (action: string) => { + JGConsole.notifyConletModel(conletId, action); + }; + + return { localize, resourceBase, vmAction, configured, + startable, stoppable, running, inUse, permissions }; + }, + template: ` + + + + + + + + + + +
+ + + + + + + + +
` + }); + app.use(JgwcPlugin, []); + app.config.globalProperties.window = window; + app.mount(previewDom); +}; + +JGConsole.registerConletFunction("org.jdrupes.vmoperator.poolaccess.PoolAccess", + "updateConfig", + function(conletId: string, type: string, resource: string) { + const conlet = JGConsole.findConletPreview(conletId); + if (!conlet) { + return; + } + const api = getApi(conlet.element().querySelector( + ":scope .jdrupes-vmoperator-poolaccess-preview"))!; + if (type === "VM") { + api.vmName = resource; + api.poolName = ""; + } else { + api.poolName = resource; + api.vmName = ""; + } + }); + +JGConsole.registerConletFunction("org.jdrupes.vmoperator.poolaccess.PoolAccess", + "updateVmDefinition", function(conletId: string, vmDefinition: any) { + const conlet = JGConsole.findConletPreview(conletId); + if (!conlet) { + return; + } + const api = getApi(conlet.element().querySelector( + ":scope .jdrupes-vmoperator-poolaccess-preview"))!; + // Add some short-cuts for rendering + vmDefinition.name = vmDefinition.metadata.name; + vmDefinition.currentCpus = vmDefinition.status.cpus; + vmDefinition.currentRam = Number(vmDefinition.status.ram); + vmDefinition.usedBy = vmDefinition.status.consoleClient || ""; + for (const condition of vmDefinition.status.conditions) { + if (condition.type === "Running") { + vmDefinition.running = condition.status === "True"; + vmDefinition.runningConditionSince + = new Date(condition.lastTransitionTime); + break; + } + } + api.vmDefinition = vmDefinition; + }); + +JGConsole.registerConletFunction("org.jdrupes.vmoperator.poolaccess.PoolAccess", + "openConsole", function(_conletId: string, mimeType: string, data: string) { + let target = document.getElementById( + "org.jdrupes.vmoperator.poolaccess.PoolAccess.target"); + if (!target) { + target = document.createElement("iframe"); + target.id = "org.jdrupes.vmoperator.poolaccess.PoolAccess.target"; + target.setAttribute("name", target.id); + target.setAttribute("style", "display: none;"); + document.querySelector("body")!.append(target); + } + const url = "data:" + mimeType + ";base64," + data; + window.open(url, target.id); + }); + +window.orgJDrupesVmOperatorPoolAccess.initEdit = (dialogDom: HTMLElement, + isUpdate: boolean) => { + if (isUpdate) { + return; + } + const app = createApp({ + setup() { + const formId = (dialogDom + .closest("*[data-conlet-id]")!).id + "-form"; + + const localize = (key: string) => { + return JGConsole.localize( + l10nBundles, JGWC.lang()!, key); + }; + + const resource = ref("vm"); + const vmNameInput = ref(""); + const poolNameInput = ref(""); + + watch(resource, (resource: string) => { + if (resource === "vm") { + poolNameInput.value = ""; + } + if (resource === "pool") + vmNameInput.value = ""; + }); + + const conletId = (dialogDom.closest( + "[data-conlet-id]")!).dataset["conletId"]!; + const conlet = JGConsole.findConletPreview(conletId); + if (conlet) { + const api = getApi(conlet.element().querySelector( + ":scope .jdrupes-vmoperator-poolaccess-preview"))!; + if (api.poolName) { + resource.value = "pool"; + } + vmNameInput.value = api.vmName; + poolNameInput.value = api.poolName; + } + + provideApi(dialogDom, { resource: () => resource.value, + name: () => resource.value === "vm" + ? vmNameInput.value : poolNameInput.value }); + + return { formId, localize, resource, vmNameInput, poolNameInput }; + } + }); + app.use(JgwcPlugin); + app.mount(dialogDom); +} + +window.orgJDrupesVmOperatorPoolAccess.applyEdit = + (dialogDom: HTMLElement, apply: boolean) => { + if (!apply) { + return; + } + const conletId = (dialogDom.closest("[data-conlet-id]")!) + .dataset["conletId"]!; + const editApi = getApi>(dialogDom!)!; + JGConsole.notifyConletModel(conletId, "selectedResource", editApi.resource(), + editApi.name()); +} + +window.orgJDrupesVmOperatorPoolAccess.confirmReset = + (conletType: string, conletId: string) => { + JGConsole.instance.closeModalDialog(conletType, conletId); + JGConsole.notifyConletModel(conletId, "resetConfirmed"); +} \ No newline at end of file diff --git a/org.jdrupes.vmoperator.poolaccess/src/org/jdrupes/vmoperator/poolaccess/browser/PoolAccess-style.scss b/org.jdrupes.vmoperator.poolaccess/src/org/jdrupes/vmoperator/poolaccess/browser/PoolAccess-style.scss new file mode 100644 index 000000000..d5de511b7 --- /dev/null +++ b/org.jdrupes.vmoperator.poolaccess/src/org/jdrupes/vmoperator/poolaccess/browser/PoolAccess-style.scss @@ -0,0 +1,104 @@ +/* + * VM-Operator + * Copyright (C) 2023 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/* + * Conlet specific styles. + */ +.jdrupes-vmoperator-poolaccess { + + span[role="button"].svg-icon { + display: inline-block; + line-height: 1; + /* Align with forkawesome */ + font-size: 14px; + fill: var(--primary); + + &[aria-disabled="true"], &[aria-disabled=""] { + fill: var(--disabled); + } + + svg { + height: 2ex; + width: 1em; + } + } + + [role=button] { + padding: 0.25rem; + + &:not([aria-disabled]):hover, &[aria-disabled='false']:hover { + box-shadow: var(--darkening); + } + } +} + +.jdrupes-vmoperator-poolaccess.jdrupes-vmoperator-poolaccess-preview { + + img { + height: 3em; + padding: 0.25rem; + + &[aria-disabled=''], &[aria-disabled='true'] { + opacity: 0.4; + } + } + + .jdrupes-vmoperator-poolaccess-preview-action-list { + white-space: nowrap; + } + + span.busy::before { + font: normal normal normal 14px/1 ForkAwesome; + font-size: 1.125em; + content: "\f1ce"; + left: 1.45em; + top: 0.7em; + color: var(--info); + position: absolute; + animation: spin 2s linear infinite; + z-index: 100; + pointer-events: none; + } +} + +.jdrupes-vmoperator-poolaccess.jdrupes-vmoperator-poolaccess-edit { + + fieldset ul li { + margin-top: 0.5em; + } + + select { + width: 15em; + } +} + +.jdrupes-vmoperator-poolaccess.jdrupes-vmoperator-poolaccess-confirm-reset { + p { + text-align: center; + } + + span[role="button"].svg-icon { + fill: var(--danger); + + svg { + width: 2.5em; + height: 2.5em; + } + } + +} diff --git a/org.jdrupes.vmoperator.poolaccess/src/org/jdrupes/vmoperator/poolaccess/browser/l10nBundles-stub.d.ts b/org.jdrupes.vmoperator.poolaccess/src/org/jdrupes/vmoperator/poolaccess/browser/l10nBundles-stub.d.ts new file mode 100644 index 000000000..8ca03f3b3 --- /dev/null +++ b/org.jdrupes.vmoperator.poolaccess/src/org/jdrupes/vmoperator/poolaccess/browser/l10nBundles-stub.d.ts @@ -0,0 +1 @@ +export default new Map>(); diff --git a/org.jdrupes.vmoperator.poolaccess/src/org/jdrupes/vmoperator/poolaccess/package-info.java b/org.jdrupes.vmoperator.poolaccess/src/org/jdrupes/vmoperator/poolaccess/package-info.java new file mode 100644 index 000000000..9a04c711f --- /dev/null +++ b/org.jdrupes.vmoperator.poolaccess/src/org/jdrupes/vmoperator/poolaccess/package-info.java @@ -0,0 +1,19 @@ +/* + * VM-Operator + * Copyright (C) 2023, 2024 Michael N. Lipp + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.jdrupes.vmoperator.poolaccess; diff --git a/org.jdrupes.vmoperator.poolaccess/tsconfig.json b/org.jdrupes.vmoperator.poolaccess/tsconfig.json new file mode 100644 index 000000000..45b2b5c5e --- /dev/null +++ b/org.jdrupes.vmoperator.poolaccess/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "es2015", + "module": "es2015", + "sourceMap": true, + "inlineSources": true, + "declaration": true, + "importHelpers": true, + "strict": true, + "moduleResolution": "node", + "experimentalDecorators": true, + "lib": ["DOM", "ES2020"], + "paths": { + "aash-plugin": ["./build/unpacked/org/jgrapes/webconsole/provider/jgwcvuecomponents/aash-vue-components/lib/AashPlugin"], + "jgconsole": ["./build/unpacked/org/jgrapes/webconsole/base/JGConsole"], + "jgwc": ["./build/unpacked/org/jgrapes/webconsole/provider/jgwcvuecomponents/jgwc-vue-components/jgwc-components"], + "l10nBundles": ["./src/org/jdrupes/vmoperator/poolaccess/browser/l10nBundles-stub"], + "vue": ["./build/unpacked/org/jgrapes/webconsole/provider/vue/vue/vue"] + } + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "l10nBundles-stub.ts"] +} diff --git a/settings.gradle b/settings.gradle index 4a3bfc89c..dcc41fc2e 100644 --- a/settings.gradle +++ b/settings.gradle @@ -13,6 +13,7 @@ rootProject.name = 'VM-Operator' include 'org.jdrupes.vmoperator.manager' include 'org.jdrupes.vmoperator.manager.events' include 'org.jdrupes.vmoperator.vmaccess' +include 'org.jdrupes.vmoperator.poolaccess' include 'org.jdrupes.vmoperator.vmmgmt' include 'org.jdrupes.vmoperator.runner.qemu' include 'org.jdrupes.vmoperator.common'