diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 0000000..ab0e7e3 --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1,128 @@ +# Upgrade to V4 + +## Overview + +In the new ns8-erpnext we build docker at runtime where you pass in the apps.json you need to build your app. + +## Architecture + +The module uses a runtime Docker image building approach where: + +- Custom apps are defined via JSON configuration +- Docker images are built on-demand based on the apps.json configuration +- Podman is used for container management + +## Configuration Settings UI + +The Settings page (`ui/src/views/Settings.vue`) provides the following configuration options: + +### 1. FQDN (Fully Qualified Domain Name) + +- **Field**: Host/URL input +- **Purpose**: Set the URL where ERPNext will be accessible +- **Example**: `erpnext.example.org` +- **Validation**: Required field + +### 2. TLS/SSL Configuration + +- **Let's Encrypt**: Toggle to enable automatic SSL certificate generation +- **HTTP to HTTPS Redirect**: Toggle to force HTTPS redirects (enabled by default) + +### 3. Frappe Version Selection + +- **Options**: + - `version-15` (default) + - `version-16` +- **Purpose**: Select the Frappe framework version to use + +### 4. App Management + +#### Adding Custom Apps + +Apps can be added via the UI modal with the following fields: + +- **App Name** (required): The application identifier +- **Repository URL** (required): Git repository URL for the app +- **Branch**: Git branch to use (defaults to "main") +- **Labels**: Comma-separated labels for the app + +#### App Management Features + +- **Add App via Form**: Opens a modal to add new apps +- **Edit App**: Modify existing app details +- **Remove App**: Delete apps from the configuration +- **Copy JSON**: Copy the current apps.json to clipboard +- **JSON Editor**: Advanced users can directly edit the apps.json in the accordion section + +#### Apps Display + +Apps are displayed in a structured list showing: + +- App Name +- URL +- Branch +- Labels +- Actions (Edit/Remove buttons) + +### 5. ERPNext Modules Selection + +- **Multi-select component** showing available modules +- **Dynamic population**: Options are generated from the apps.json configuration +- **Pre-selected values**: Previously selected modules are restored when loading configuration +- **Filtering**: When apps are removed, their modules are automatically deselected + +### 6. Podman Images (Advanced) + +- **View**: Lists all built Podman images with details (Repository, Tag, ID, Created, Size) +- **Refresh**: Button to refresh the images list +- **Purpose**: Monitor built Docker images + +## Data Flow + +1. **Configuration Loading** (`getConfiguration`): + - Fetches current configuration from backend + - Decodes base64 appJson + - Restores selected modules + - Populates all form fields + +2. **App JSON Processing**: + - Stored as base64 encoded string in backend + - Parsed into structured list for display + - Used to generate multi-select options + - Filters selected modules to only include valid apps + +3. **Configuration Saving** (`configureModule`): + - Validates host field + - Encodes appJson to base64 + - Sends all configuration data to backend + - Triggers module reconfiguration + +## JSON Format + +The `app_json` field expects an array of app objects: + +```json +[ + { + "app_name": "my-custom-app", + "url": "https://github.com/user/repo", + "branch": "main", + "labels": "production,custom" + } +] +``` + +## Important Notes + +1. **App Name Consistency**: The multi-select uses `app_name` or `name` field to match selected modules with available options +2. **Base64 Encoding**: The appJson is base64 encoded when sent to the backend +3. **Validation**: Apps must have at least an app_name and URL +4. **Podman Integration**: The module interfaces with Podman for container management +5. **Dynamic Options**: The ERPNext Modules multi-select options are dynamically generated from the apps.json + +**NOTE** + +- Always ensure apps.json is valid JSON before saving +- Selected modules are filtered to only include apps that exist in the configuration +- Podman images are specific to the configured apps and Frappe version +- Make sure you install the apps that were previously installed to avoid installtion issues diff --git a/build-images.sh b/build-images.sh index 2e48cad..64309b4 100644 --- a/build-images.sh +++ b/build-images.sh @@ -15,8 +15,6 @@ repobase="${REPOBASE:-ghcr.io/geniusdynamics}" # Configure the image name reponame="erpnext" -app_version="15.92.1" - # Create a new empty container image container=$(buildah from scratch) @@ -47,7 +45,7 @@ buildah config --entrypoint=/ \ --label="org.nethserver.authorizations=traefik@node:routeadm" \ --label="org.nethserver.tcp-ports-demand=1" \ --label="org.nethserver.rootfull=0" \ - --label="org.nethserver.images=docker.io/mariadb:10.11.5 docker.io/geniusdynamics/erpnext:${app_version} docker.io/redis:6.2-alpine" \ + --label="org.nethserver.images=docker.io/mariadb:10.11.5 docker.io/redis:6.2-alpine" \ "${container}" # Commit the image buildah commit "${container}" "${repobase}/${reponame}" diff --git a/imageroot/actions/build-docker-image/20configure_build_vars b/imageroot/actions/build-docker-image/20configure_build_vars new file mode 100755 index 0000000..62235c0 --- /dev/null +++ b/imageroot/actions/build-docker-image/20configure_build_vars @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 + +# +# Copyright (C) 2022 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-3.0-or-later +# + +import json +import sys +import agent + +# Try to parse the stdin as JSON. +# If parsing fails, output everything to stderr +data = json.load(sys.stdin) + +# This is specific to you module, so you need to change it accordingly. + +ERP_NEXT_MODULES = data.get("erpSelectedModules", []) +APP_JSON = data.get("appJson") +FRAPPE_VERSION = data.get("frappeVersion", "version-15") + +agent.write_envfile( + "erpnext-modules.env", {"ERP_NEXT_MODULES": ",".join(ERP_NEXT_MODULES) if ERP_NEXT_MODULES else "", "APPS_JSON": APP_JSON, "FRAPPE_VERSION": FRAPPE_VERSION} +) + +agent.dump_env() diff --git a/imageroot/actions/build-docker-image/30build_docker_image b/imageroot/actions/build-docker-image/30build_docker_image new file mode 100755 index 0000000..baa248a --- /dev/null +++ b/imageroot/actions/build-docker-image/30build_docker_image @@ -0,0 +1,6 @@ +#!/bin/bash + +echo "Building Docker Image" +../configure-module/30build_docker_image + +echo "Docker Image Built" diff --git a/imageroot/actions/configure-module/10configure_environment_vars b/imageroot/actions/configure-module/10configure_environment_vars index fddab34..68eed19 100755 --- a/imageroot/actions/configure-module/10configure_environment_vars +++ b/imageroot/actions/configure-module/10configure_environment_vars @@ -13,12 +13,16 @@ import agent # If parsing fails, output everything to stderr data = json.load(sys.stdin) -#This is specific to you module, so you need to change it accordingly. +# This is specific to you module, so you need to change it accordingly. ERP_NEXT_MODULES = data.get("erpSelectedModules", []) +APP_JSON = data.get("appJson") +FRAPPE_VERSION = data.get("frappeVersion", "version-15") -agent.write_envfile("erpnext-modules.env", { - "ERP_NEXT_MODULES": ERP_NEXT_MODULES -}) +ERP_NEXT_MODULES_STR = ",".join(ERP_NEXT_MODULES) if ERP_NEXT_MODULES else "" + +agent.write_envfile( + "erpnext-modules.env", {"ERP_NEXT_MODULES": ERP_NEXT_MODULES_STR, "APPS_JSON": APP_JSON, "FRAPPE_VERSION": FRAPPE_VERSION} +) agent.dump_env() diff --git a/imageroot/actions/configure-module/30build_docker_image b/imageroot/actions/configure-module/30build_docker_image new file mode 100755 index 0000000..7c7c0ed --- /dev/null +++ b/imageroot/actions/configure-module/30build_docker_image @@ -0,0 +1,178 @@ +#!/bin/bash + +set -e +source erpnext-modules.env +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Function to print colored output +print_info() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Check if podman is installed +if ! command -v podman &>/dev/null; then + print_error "podman is required but not installed. Please install podman first." + exit 1 +fi + +# Check if jq is installed +if ! command -v jq &>/dev/null; then + print_error "jq is required but not installed. Please install jq first." + exit 1 +fi + +# Function to fetch latest ERPNext tag from GitHub based on FRAPPE_VERSION +fetch_erpnext_tag() { + local major_version="${FRAPPE_VERSION##version-}" + local api_url="https://api.github.com/repos/frappe/erpnext/tags" + + local tags_json + if ! tags_json=$(curl -fsSL "$api_url" 2>/dev/null); then + print_warning "Failed to fetch tags from GitHub, using default" + return 1 + fi + + local first_tag + if ! first_tag=$(echo "$tags_json" | jq -r ".[] | select(.name | startswith(\"v${major_version}.\")) | .name" 2>/dev/null | head -1); then + print_warning "Failed to parse tags, using default" + return 1 + fi + + if [ -z "$first_tag" ] || [ "$first_tag" = "null" ]; then + print_warning "No v${major_version} tags found, using default" + return 1 + fi + + echo "$first_tag" + return 0 +} + +# Cache file for app_json hash +CACHE_FILE=".app_json_cache" +CURRENT_APPS_JSON="$APPS_JSON" + +# Function to compute hash of the apps JSON +compute_apps_hash() { + echo -n "$1" | sha256sum | cut -d' ' -f1 +} + +# Get current apps JSON hash +CURRENT_HASH=$(compute_apps_hash "$CURRENT_APPS_JSON") + +# Check if cache exists and compare +if [ -f "$CACHE_FILE" ]; then + CACHED_HASH=$(cat "$CACHE_FILE") + if [ "$CURRENT_HASH" = "$CACHED_HASH" ]; then + print_warning "App configuration unchanged. Skipping Docker image build." + print_info "To force a rebuild, delete the cache file: rm $CACHE_FILE" + exit 0 + else + print_info "App configuration changed. Proceeding with Docker image build..." + fi +else + print_info "No cache found. Building Docker image..." +fi + +# Fetch latest ERPNext tag from GitHub based on FRAPPE_VERSION +ERPNEXT_TAG=$(fetch_erpnext_tag) || true + +if [ -z "${ERPNEXT_TAG:-}" ]; then + print_warning "Could not fetch ERPNext tag, using fallback" + ERPNEXT_TAG="v${FRAPPE_VERSION##version-}.0.0" +fi + +print_info "Using ERPNext tag: $ERPNEXT_TAG" + +ERPNEXT_IMAGE="frappe/erpnext:${ERPNEXT_TAG}" + +print_info "ERPNEXT_IMAGE forcibly set to: $ERPNEXT_IMAGE" +# Extract image name and tag from ERPNEXT_IMAGE +BASE_IMAGE="$ERPNEXT_IMAGE" +# Extract repository and tag +IMAGE_REPO=$(echo "$ERPNEXT_IMAGE" | cut -d':' -f1) +IMAGE_TAG=$(echo "$ERPNEXT_IMAGE" | cut -d':' -f2) + +# Use the same tag for custom image +CUSTOM_IMAGE_NAME="${IMAGE_REPO}" +CUSTOM_IMAGE_TAG="$IMAGE_TAG" + +print_info "Base Image: $BASE_IMAGE" +print_info "Custom Image will be: $CUSTOM_IMAGE_NAME:$CUSTOM_IMAGE_TAG" + +print_info "Apps to be installed:" +echo "$APPS_JSON" + +FRAPPE_BRANCH="${FRAPPE_VERSION}" + +print_info "Using Frappe branch: $FRAPPE_BRANCH" +print_info "Building custom ERPNext image with Podman..." + +# Build the image with Podman +podman build --network=host \ + --jobs=4 \ + --build-arg APPS_JSON_BASE64="$APPS_JSON" \ + --build-arg FRAPPE_BRANCH="$FRAPPE_BRANCH" \ + -t "$CUSTOM_IMAGE_NAME:$CUSTOM_IMAGE_TAG" \ + -f ../actions/configure-module/Dockerfile \ + . + +if [ $? -eq 0 ]; then + print_info "Build completed successfully!" + print_info "Image tagged as: $CUSTOM_IMAGE_NAME:$CUSTOM_IMAGE_TAG" + + # Also tag as latest + podman tag "$CUSTOM_IMAGE_NAME:$CUSTOM_IMAGE_TAG" "$CUSTOM_IMAGE_NAME:latest" + + # Tag with the same name as base image (for drop-in replacement) + podman tag "$CUSTOM_IMAGE_NAME:$CUSTOM_IMAGE_TAG" "$BASE_IMAGE" + print_info "Also tagged as: $BASE_IMAGE (drop-in replacement)" + + echo "" + print_info "To push the image to a registry:" + echo " podman push $CUSTOM_IMAGE_NAME:$CUSTOM_IMAGE_TAG" + echo " podman push $CUSTOM_IMAGE_NAME:latest" + echo " podman push $BASE_IMAGE" + + echo "" + print_info "Image size:" + podman images "$CUSTOM_IMAGE_NAME:$CUSTOM_IMAGE_TAG" --format "table {{.Repository}}\t{{.Tag}}\t{{.Size}}" + ENV_FILE="environment" + + # Ensure env file exists + [ -f "$ENV_FILE" ] || { + print_error "Env file not found" + exit 1 + } + + CUSTOM_IMAGE_FULL="$CUSTOM_IMAGE_NAME:$CUSTOM_IMAGE_TAG" + + # Update or add ERPNEXT_IMAGE + if grep -q '^ERPNEXT_IMAGE=' "$ENV_FILE"; then + sed -i "s|^ERPNEXT_IMAGE=.*|ERPNEXT_IMAGE=$CUSTOM_IMAGE_FULL|" "$ENV_FILE" + else + echo "ERPNEXT_IMAGE=$CUSTOM_IMAGE_FULL" >>"$ENV_FILE" + fi + + print_info "Updated ERPNEXT_IMAGE in $ENV_FILE to:" + print_info "$CUSTOM_IMAGE_FULL" print_info "To use this image, it's already tagged as: $BASE_IMAGE" + print_info "Your existing docker-compose or deployment will automatically use the custom image!" + + # Update the cache with the new hash + echo "$CURRENT_HASH" >"$CACHE_FILE" + print_info "Cache updated." +else + print_error "Build failed!" + exit 1 +fi diff --git a/imageroot/actions/configure-module/Dockerfile b/imageroot/actions/configure-module/Dockerfile new file mode 100644 index 0000000..54d1650 --- /dev/null +++ b/imageroot/actions/configure-module/Dockerfile @@ -0,0 +1,60 @@ +ARG FRAPPE_BRANCH=version-15 + +FROM docker.io/frappe/build:${FRAPPE_BRANCH} AS builder + +ARG FRAPPE_BRANCH=version-15 +ARG FRAPPE_PATH=https://github.com/frappe/frappe +ARG APPS_JSON_BASE64 + +USER root + +RUN if [ -n "${APPS_JSON_BASE64}" ]; then \ + mkdir /opt/frappe && echo "${APPS_JSON_BASE64}" | base64 -d > /opt/frappe/apps.json; \ + fi + +USER frappe + +RUN yarn config set registry https://registry.npmjs.org/ && \ + yarn config set network-timeout 600000 +RUN export APP_INSTALL_ARGS="" && \ + if [ -n "${APPS_JSON_BASE64}" ]; then \ + export APP_INSTALL_ARGS="--apps_path=/opt/frappe/apps.json"; \ + fi && \ + bench init ${APP_INSTALL_ARGS}\ + --frappe-branch=${FRAPPE_BRANCH} \ + --frappe-path=${FRAPPE_PATH} \ + --no-procfile \ + --no-backups \ + --skip-redis-config-generation \ + --verbose \ + /home/frappe/frappe-bench && \ + cd /home/frappe/frappe-bench && \ + echo "{}" > sites/common_site_config.json && \ + find apps -mindepth 1 -path "*/.git" | xargs rm -fr + +FROM docker.io/frappe/base:${FRAPPE_BRANCH} AS backend + +USER frappe + +COPY --from=builder --chown=frappe:frappe /home/frappe/frappe-bench /home/frappe/frappe-bench + +WORKDIR /home/frappe/frappe-bench + +VOLUME [ \ + "/home/frappe/frappe-bench/sites", \ + "/home/frappe/frappe-bench/sites/assets", \ + "/home/frappe/frappe-bench/logs" \ + ] + +CMD [ \ + "/home/frappe/frappe-bench/env/bin/gunicorn", \ + "--chdir=/home/frappe/frappe-bench/sites", \ + "--bind=0.0.0.0:8000", \ + "--threads=4", \ + "--workers=2", \ + "--worker-class=gthread", \ + "--worker-tmp-dir=/dev/shm", \ + "--timeout=120", \ + "--preload", \ + "frappe.app:application" \ + ] diff --git a/imageroot/actions/configure-module/validate-input.json b/imageroot/actions/configure-module/validate-input.json index 575ba9e..669b11a 100644 --- a/imageroot/actions/configure-module/validate-input.json +++ b/imageroot/actions/configure-module/validate-input.json @@ -7,14 +7,16 @@ { "host": "erpnext.domain.org", "http2https": true, - "lets_encrypt": true + "lets_encrypt": true, + "appJson": "W3sidXJsIjoiaHR0cHM6Ly9naXRodWIuY29tL2ZyYXBwZS9lcnBuZXh0IiwiYnJhbmNoIjoidmVyc2lvbi0xNSIsImFwcF9uYW1lIjoiZXJwbmV4dCJ9XQ==" } ], "type": "object", "required": [ "host", "http2https", - "lets_encrypt" + "lets_encrypt", + "appJson" ], "properties": { "host": { @@ -31,6 +33,17 @@ "type": "boolean", "title": "HTTP to HTTPS redirection", "description": "Redirect all the HTTP requests to HTTPS" + }, + "appJson": { + "type": "string", + "title": "App JSON configuration", + "description": "Base64 encoded JSON array containing app configurations with url, branch, and app_name" + }, + "frappeVersion": { + "type": "string", + "title": "Frappe version", + "description": "Frappe version branch to use (e.g., version-15 or version-16)", + "enum": ["version-15", "version-16"] } } } \ No newline at end of file diff --git a/imageroot/actions/get-configuration/20read b/imageroot/actions/get-configuration/20read index 295294c..e6fadb8 100755 --- a/imageroot/actions/get-configuration/20read +++ b/imageroot/actions/get-configuration/20read @@ -25,13 +25,20 @@ config["lets_encrypt"] = os.getenv("TRAEFIK_LETS_ENCRYPT") == "True" # Load erpnext-modules.env file if os.path.exists("erpnext-modules.env"): data = agent.read_envfile("erpnext-modules.env") - config["erpSelectedModules"] = data.get("ERP_NEXT_MODULES", []) + modules_str = data.get("ERP_NEXT_MODULES", "") + config["erpSelectedModules"] = modules_str.split(",") if modules_str else [] + config["appJson"] = data.get("APPS_JSON", "") + config["frappeVersion"] = data.get("FRAPPE_VERSION", "version-15") else: config["erpSelectedModules"] = [] + config["appJson"] = "" + config["frappeVersion"] = "version-15" if os.path.exists("backup_paths.env"): data = agent.read_envfile("backup_paths.env") config["hasBackup"] = True +else: + config["hasBackup"] = False # Dump the configuration to stdout diff --git a/imageroot/actions/get-configuration/validate-output.json b/imageroot/actions/get-configuration/validate-output.json index 80368ff..9b9a228 100644 --- a/imageroot/actions/get-configuration/validate-output.json +++ b/imageroot/actions/get-configuration/validate-output.json @@ -31,6 +31,26 @@ "type": "boolean", "title": "HTTP to HTTPS redirection", "description": "Redirect all the HTTP requests to HTTPS" + }, + "erpSelectedModules": { + "type": "array", + "title": "ERP Next selected modules", + "description": "List of selected ERP Next modules" + }, + "appJson": { + "type": "string", + "title": "App JSON configuration", + "description": "Base64 encoded JSON array containing app configurations" + }, + "frappeVersion": { + "type": "string", + "title": "Frappe version", + "description": "Frappe version branch to use (e.g., version-15 or version-16)" + }, + "hasBackup": { + "type": "boolean", + "title": "Has backup", + "description": "Whether a backup exists for this instance" } } } \ No newline at end of file diff --git a/imageroot/actions/podman-images-module/10podman-cm b/imageroot/actions/podman-images-module/10podman-cm new file mode 100755 index 0000000..4d35eeb --- /dev/null +++ b/imageroot/actions/podman-images-module/10podman-cm @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 + +# +# Copyright (C) 2022 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-3.0-or-later +# + +# +# Get podman images +# + +import os +import sys +import json +import subprocess +import agent + +# Prepare return variable +images_data = {} + +try: + # Run podman images command to get images in JSON format + result = subprocess.run( + ["podman", "images", "--format", "json"], + capture_output=True, + text=True, + check=True, + ) + images = json.loads(result.stdout) + images_data["images"] = images + images_data["error"] = None +except subprocess.CalledProcessError as e: + images_data["images"] = [] + images_data["error"] = f"Failed to get podman images: {e.stderr}" +except json.JSONDecodeError as e: + images_data["images"] = [] + images_data["error"] = f"Failed to parse podman images output: {str(e)}" +except Exception as e: + images_data["images"] = [] + images_data["error"] = f"Unexpected error: {str(e)}" + +# Dump the images data to stdout +json.dump(images_data, fp=sys.stdout) diff --git a/imageroot/actions/podman-images-module/validate-output.json b/imageroot/actions/podman-images-module/validate-output.json new file mode 100644 index 0000000..7096cd1 --- /dev/null +++ b/imageroot/actions/podman-images-module/validate-output.json @@ -0,0 +1,65 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Get podman images", + "$id": "http://nethserver.org/json-schema/task/input/erpnext/podman-images", + "description": "Get list of podman images", + "examples": [ + { + "images": [ + { + "id": "sha256:abc123", + "repositories": ["docker.io/library/nginx:latest"], + "tags": ["latest"], + "created": "2023-01-01 12:00:00 +0000 UTC", + "size": "142MB" + } + ], + "error": null + } + ], + "type": "object", + "required": ["images", "error"], + "properties": { + "images": { + "type": "array", + "title": "Podman images list", + "description": "List of podman images with their details", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Image ID" + }, + "repositories": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Image repositories" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Image tags" + }, + "created": { + "type": "string", + "description": "Creation timestamp" + }, + "size": { + "type": "string", + "description": "Image size" + } + } + } + }, + "error": { + "type": ["string", "null"], + "title": "Error message", + "description": "Error message if the operation failed, null otherwise" + } + } +} diff --git a/imageroot/bin/create-site b/imageroot/bin/create-site index e519be8..de3b90e 100755 --- a/imageroot/bin/create-site +++ b/imageroot/bin/create-site @@ -36,3 +36,5 @@ else done fi bench --site frontend migrate +bench clear-cache +bench clear-website-cache diff --git a/imageroot/systemd/user/erp-next.service b/imageroot/systemd/user/erp-next.service index dfbc871..c885b5c 100644 --- a/imageroot/systemd/user/erp-next.service +++ b/imageroot/systemd/user/erp-next.service @@ -24,7 +24,9 @@ ExecStartPre=/usr/bin/podman pod create --infra-conmon-pidfile %t/erp-next.pid \ --pod-id-file %t/erp-next.pod-id \ --name erp-next \ --publish 127.0.0.1:${TCP_PORT}:8080 \ - --replace + --replace \ + --network=slirp4netns:allow_host_loopback=true \ + --add-host=accountprovider:10.0.2.2 ExecStart=/usr/bin/podman pod start --pod-id-file %t/erp-next.pod-id ExecStop=/usr/bin/podman pod stop --ignore --pod-id-file %t/erp-next.pod-id -t 10 ExecStopPost=/usr/bin/podman pod rm --ignore -f --pod-id-file %t/erp-next.pod-id diff --git a/ui/public/i18n/de/translation.json b/ui/public/i18n/de/translation.json index b3e4898..f967146 100644 --- a/ui/public/i18n/de/translation.json +++ b/ui/public/i18n/de/translation.json @@ -15,7 +15,18 @@ "test_field": "Testfeld", "configure_instance": "{instance} konfigurieren", "save": "Speichern", - "title": "Einstellungen" + "title": "Einstellungen", + "erpnext_fqdn": "ERPNext Hostname (FQDN)", + "lets_encrypt": "LE Zertifikat anfordern", + "http_to_https": "HTTPS erzwingen", + "enabled": "Aktiviert", + "disabled": "Deaktiviert", + "advanced": "Erweitert", + "configuring": "Konfiguration", + "instance_configuration": "ERPNext konfigurieren", + "domain_already_used_in_traefik": "Domain wird bereits in Traefik verwendet", + "app_json_must_be_array": "App JSON muss ein Array von Objekten sein", + "invalid_json_format": "Ungültiges JSON Format" }, "common": { "required": "erforderlich", @@ -49,9 +60,20 @@ "get-status": "Status abfragen", "configure-module": "Modul konfigurieren", "get-configuration": "Konfiguration abrufen", - "get-name": "Name abrufen" + "get-name": "Name abrufen", + "build-docker-image": "Docker-Image bauen" }, "task": { "cannot_create_task": "{task} kann nicht erstellt werden" + }, + "build": { + "title": "Docker-Image bauen", + "force_rebuild": "Neubau erzwingen", + "no": "Nein", + "yes": "Ja", + "apps_to_build": "Apps die gebaut werden sollen", + "build_image": "Image bauen", + "building_image": "Docker-Image wird gebaut", + "please_wait": "Bitte warten..." } } diff --git a/ui/public/i18n/en/translation.json b/ui/public/i18n/en/translation.json index a5af924..dffe19e 100644 --- a/ui/public/i18n/en/translation.json +++ b/ui/public/i18n/en/translation.json @@ -21,7 +21,7 @@ "no_volumes": "No volumes", "webapp": "ERPNEXT Webapp", "open_webapp": "Open ERPNEXT", - "not_configured":"ERPNEXT is not configured", + "not_configured": "ERPNEXT is not configured", "configure": "Configure" }, "settings": { @@ -36,7 +36,14 @@ "advanced": "Advanced", "configuring": "Configuring", "instance_configuration": "Configure ErpNext", - "domain_already_used_in_traefik": "Domain already used in traefik" + "domain_already_used_in_traefik": "Domain already used in traefik", + "app_json_must_be_array": "App JSON must be an array of objects", + "invalid_json_format": "Invalid JSON format", + "frappe_version": "Frappe version", + "app_name": "App Name", + "repository_url": "Repository URL", + "branch": "Branch", + "labels": "Labels" }, "about": { "title": "About" @@ -47,6 +54,17 @@ "task": { "cannot_create_task": "Cannot create task {action}" }, + "build": { + "title": "Build Docker Image", + "force_rebuild": "Force rebuild", + "no": "No", + "yes": "Yes", + "apps_to_build": "Apps to be built", + "build_image": "Build Image", + "building_image": "Building Docker image", + "please_wait": "Please wait...", + "frappe_version": "Frappe version" + }, "action": { "get-status": "Get status", "get-configuration": "Get configuration", @@ -55,7 +73,8 @@ "get-name": "Get name", "list-backup-repositories": "List backup repositories", "list-backups": "List backups", - "list-installed-modules": "List installed modules" + "list-installed-modules": "List installed modules", + "build-docker-image": "Build Docker image" }, "error": { "error": "Error", diff --git a/ui/public/i18n/es/translation.json b/ui/public/i18n/es/translation.json index f2fb14f..e1540f0 100644 --- a/ui/public/i18n/es/translation.json +++ b/ui/public/i18n/es/translation.json @@ -15,7 +15,18 @@ "test_field": "Campo de prueba", "configure_instance": "Configurar {instance}", "save": "Guardar", - "title": "Ajustes" + "title": "Ajustes", + "erpnext_fqdn": "Nombre de host ERPNext (FQDN)", + "lets_encrypt": "Solicitar certificado LE", + "http_to_https": "Forzar HTTPS", + "enabled": "Activado", + "disabled": "Desactivado", + "advanced": "Avanzado", + "configuring": "Configurando", + "instance_configuration": "Configurar ERPNext", + "domain_already_used_in_traefik": "Dominio ya utilizado en Traefik", + "app_json_must_be_array": "App JSON debe ser un array de objetos", + "invalid_json_format": "Formato JSON inválido" }, "common": { "required": "Obligatorio", @@ -49,9 +60,20 @@ "get-status": "Obtener el estado", "configure-module": "Configurar módulo", "get-configuration": "Obtener la configuración", - "get-name": "Obtener el nombre" + "get-name": "Obtener el nombre", + "build-docker-image": "Construir imagen Docker" }, "task": { "cannot_create_task": "No se puede crear la tarea {action}" + }, + "build": { + "title": "Construir imagen Docker", + "force_rebuild": "Forzar reconstrucción", + "no": "No", + "yes": "Sí", + "apps_to_build": "Apps a construir", + "build_image": "Construir imagen", + "building_image": "Construyendo imagen Docker", + "please_wait": "Por favor espere..." } } diff --git a/ui/public/i18n/eu/translation.json b/ui/public/i18n/eu/translation.json index 1bb747c..42555db 100644 --- a/ui/public/i18n/eu/translation.json +++ b/ui/public/i18n/eu/translation.json @@ -15,7 +15,18 @@ "test_field": "Proba-eremua", "configure_instance": "{instance} konfiguratu", "save": "Gorde", - "title": "Ezarpenak" + "title": "Ezarpenak", + "erpnext_fqdn": "ERPNext Hostname (FQDN)", + "lets_encrypt": "LE Ziurtagiria eskatu", + "http_to_https": "HTTPS behartu", + "enabled": "Gaituta", + "disabled": "Ezgaituta", + "advanced": "Aurreratua", + "configuring": "Konfiguratzen", + "instance_configuration": "ERPNext konfiguratu", + "domain_already_used_in_traefik": "Domeinua Traefiken dagoeneko erabilita", + "app_json_must_be_array": "App JSON objektuen array bat izan behar da", + "invalid_json_format": "JSON formatu baliogabea" }, "common": { "required": "Beharrezkoa", @@ -49,9 +60,20 @@ "get-status": "Egoera berreskuratu", "configure-module": "Modulua konfiguratu", "get-configuration": "Konfigurazioa berreskuratu", - "get-name": "Izena berreskuratu" + "get-name": "Izena berreskuratu", + "build-docker-image": "Docker irudia eraiki" }, "task": { "cannot_create_task": "Ezin da zeregina {action} sortu" + }, + "build": { + "title": "Docker irudia eraiki", + "force_rebuild": "Birsortu behartu", + "no": "Ez", + "yes": "Bai", + "apps_to_build": "Eraiki beharreko app-ak", + "build_image": "Irudia eraiki", + "building_image": "Docker irudia eraikitzen", + "please_wait": "Mesedez itxaron..." } } diff --git a/ui/public/i18n/it/translation.json b/ui/public/i18n/it/translation.json index bac7a02..8a37ac7 100644 --- a/ui/public/i18n/it/translation.json +++ b/ui/public/i18n/it/translation.json @@ -24,7 +24,18 @@ "save": "Salva", "title": "Impostazioni", "configure_instance": "Configura {instance}", - "test_field": "Campo di test" + "test_field": "Campo di test", + "erpnext_fqdn": "Hostname ERPNext (FQDN)", + "lets_encrypt": "Richiedi certificato LE", + "http_to_https": "Forza HTTPS", + "enabled": "Attivato", + "disabled": "Disattivato", + "advanced": "Avanzato", + "configuring": "Configurazione", + "instance_configuration": "Configura ERPNext", + "domain_already_used_in_traefik": "Dominio già utilizzato in Traefik", + "app_json_must_be_array": "App JSON deve essere un array di oggetti", + "invalid_json_format": "Formato JSON non valido" }, "error": { "error": "Errore", @@ -46,11 +57,22 @@ "get-name": "Visualizza nome", "list-backup-repositories": "Elenca repository di backup", "list-backups": "Elenca backup", - "list-installed-modules": "Elenco moduli installati" + "list-installed-modules": "Elenco moduli installati", + "build-docker-image": "Compila immagine Docker" }, "task": { "cannot_create_task": "Impossibile eseguire task {action}" }, + "build": { + "title": "Compila immagine Docker", + "force_rebuild": "Forza ricompilazione", + "no": "No", + "yes": "Sì", + "apps_to_build": "App da compilare", + "build_image": "Compila immagine", + "building_image": "Compilazione immagine Docker", + "please_wait": "Attendere prego..." + }, "about": { "title": "Informazioni" } diff --git a/ui/public/i18n/pt/translation.json b/ui/public/i18n/pt/translation.json index 67db8e5..6e19a2c 100644 --- a/ui/public/i18n/pt/translation.json +++ b/ui/public/i18n/pt/translation.json @@ -15,7 +15,18 @@ "test_field": "Campo de teste", "configure_instance": "Configurar {instância}", "save": "Gravar", - "title": "Configurações" + "title": "Configurações", + "erpnext_fqdn": "Hostname ERPNext (FQDN)", + "lets_encrypt": "Solicitar certificado LE", + "http_to_https": "Forçar HTTPS", + "enabled": "Ativado", + "disabled": "Desativado", + "advanced": "Avançado", + "configuring": "Configurando", + "instance_configuration": "Configurar ERPNext", + "domain_already_used_in_traefik": "Domínio já utilizado no Traefik", + "app_json_must_be_array": "App JSON deve ser uma matriz de objetos", + "invalid_json_format": "Formato JSON inválido" }, "common": { "required": "Obrigatório", @@ -49,9 +60,20 @@ "get-status": "Obter status", "configure-module": "Configurar módulo", "get-configuration": "Obter configuração", - "get-name": "Obter nome" + "get-name": "Obter nome", + "build-docker-image": "Construir imagem Docker" }, "task": { "cannot_create_task": "Não é possível criar a tarefa {action}" + }, + "build": { + "title": "Construir imagem Docker", + "force_rebuild": "Forçar reconstrução", + "no": "Não", + "yes": "Sim", + "apps_to_build": "Apps a construir", + "build_image": "Construir imagem", + "building_image": "A construir imagem Docker", + "please_wait": "Por favor aguarde..." } } diff --git a/ui/public/i18n/pt_BR/translation.json b/ui/public/i18n/pt_BR/translation.json index 58daa7d..a8c1b6d 100644 --- a/ui/public/i18n/pt_BR/translation.json +++ b/ui/public/i18n/pt_BR/translation.json @@ -23,13 +23,37 @@ "title": "About" }, "action": { - "list-backup-repositories": "List backup repositories", - "list-backups": "List backups" + "list-backup-repositories": "Listar repositórios de backup", + "list-backups": "Listar backups", + "build-docker-image": "Compilar imagem Docker" }, "task": { "cannot_create_task": "Cannot create task {action}" }, "settings": { - "title": "Settings" + "title": "Configurações", + "save": "Salvar", + "configure_instance": "Configurar {instance}", + "erpnext_fqdn": "Hostname ERPNext (FQDN)", + "lets_encrypt": "Solicitar certificado LE", + "http_to_https": "Forçar HTTPS", + "enabled": "Ativado", + "disabled": "Desativado", + "advanced": "Avançado", + "configuring": "Configurando", + "instance_configuration": "Configurar ERPNext", + "domain_already_used_in_traefik": "Domínio já utilizado no Traefik", + "app_json_must_be_array": "App JSON deve ser uma matriz de objetos", + "invalid_json_format": "Formato JSON inválido" + }, + "build": { + "title": "Compilar imagem Docker", + "force_rebuild": "Forçar recompilação", + "no": "Não", + "yes": "Sim", + "apps_to_build": "Apps para compilar", + "build_image": "Compilar imagem", + "building_image": "Compilando imagem Docker", + "please_wait": "Por favor aguarde..." } } diff --git a/ui/src/components/AppSideMenuContent.vue b/ui/src/components/AppSideMenuContent.vue index 87e1201..fd14f85 100644 --- a/ui/src/components/AppSideMenuContent.vue +++ b/ui/src/components/AppSideMenuContent.vue @@ -36,12 +36,19 @@ {{ $t("about.title") }} {{ $t("backup.title") }} + + + {{ $t("build.title") }} + diff --git a/ui/src/router/index.js b/ui/src/router/index.js index c0d670e..636eea5 100644 --- a/ui/src/router/index.js +++ b/ui/src/router/index.js @@ -22,14 +22,23 @@ const routes = [ component: Settings, }, { - path: "/backup-restore", - name: "backup-restore", + path: "/backuprestore", + name: "backuprestore", // route level code-splitting // this generates a separate chunk (about.[hash].js) for this route // which is lazy-loaded when the route is visited. component: () => import(/* webpackChunkName: "about" */ "../views/BackupRestore.vue"), }, + { + path: "/builddocker", + name: "builddocker", + // route level code-splitting + // this generates a separate chunk (about.[hash].js) for this route + // which is lazy-loaded when the route is visited. + component: () => + import(/* webpackChunkName: "about" */ "../views/BuildDocker.vue"), + }, { path: "/about", name: "About", diff --git a/ui/src/views/BackupRestore.vue b/ui/src/views/BackupRestore.vue index 5e7358c..8faf88c 100644 --- a/ui/src/views/BackupRestore.vue +++ b/ui/src/views/BackupRestore.vue @@ -87,7 +87,7 @@ import { } from "@nethserver/ns8-ui-lib"; export default { - name: "Settings", + name: "BackupRestore", mixins: [ TaskService, IconService, @@ -96,7 +96,7 @@ export default { PageTitleService, ], pageTitle() { - return this.$t("settings.title") + " - " + this.appName; + return this.$t("backup.title") + " - " + this.appName; }, data() { return { diff --git a/ui/src/views/BuildDocker.vue b/ui/src/views/BuildDocker.vue new file mode 100644 index 0000000..5d0b649 --- /dev/null +++ b/ui/src/views/BuildDocker.vue @@ -0,0 +1,312 @@ + + + + + + diff --git a/ui/src/views/Settings.vue b/ui/src/views/Settings.vue index 1be871b..60aa696 100644 --- a/ui/src/views/Settings.vue +++ b/ui/src/views/Settings.vue @@ -61,31 +61,275 @@ $t("settings.enabled") }} + + + version-15 + version-16 + + + + +

Manage Apps

+
+
+ + + +
+
+
+

No apps added yet

+
+ + + + +
+
+ + Add App via Form + + +
+
+
+
+
Selected Modules: {{ erpSelectedModules }}
+ + + + + + + + @@ -147,238 +391,29 @@ export default { isHttpToHttpsEnabled: true, hasBackup: false, - erpNextModules: [ - { - label: "ERPNext", - value: "erpnext", - name: "erpnext", - disabled: false, - }, - { - label: "Payments", - value: "payments", - name: "payments", - disabled: false, - }, - { - label: "Navari CSF KE", - value: "csf_ke", - name: "csf_ke", - disabled: false, - }, - { label: "HRMS", value: "hrms", name: "hrms", disabled: false }, - { - label: "Mpesa Payments", - value: "frappe_mpsa_payments", - name: "frappe_mpsa_payments", - disabled: false, - }, - { - label: "Attendance Timesheet", - value: "nl-attendance-timesheet", - name: "nl-attendance-timesheet", - disabled: false, - }, - { - label: "Piece Rate Pay", - value: "nl-piece-rate-pay", - name: "nl-piece-rate-pay", - disabled: false, - }, - { - label: "Whatsapp (Frappe)", - value: "frappe_whatsapp", - name: "frappe_whatsapp", - disabled: false, - }, - { - label: "Whatsapp Chat", - value: "whatsapp_chat", - name: "whatsapp_chat", - disabled: false, - }, - { - label: "Education", - value: "education", - name: "education", - disabled: false, - }, - { label: "LMS", value: "lms", name: "lms", disabled: false }, - { label: "Wiki", value: "wiki", name: "wiki", disabled: false }, - { - label: "Paystack", - value: "frappe_paystack", - name: "frappe_paystack", - disabled: false, - }, - { - label: "Print Designer", - value: "print_designer", - name: "print_designer", - disabled: false, - }, - { - label: "Webshop", - value: "webshop", - name: "webshop", - disabled: false, - }, - { - label: "PibiDAV", - value: "pibiDAV", - name: "pibiDAV", - disabled: false, - }, - { - label: "PibiCard", - value: "pibicard", - name: "pibicard", - disabled: false, - }, - { - label: "Lending", - value: "lending", - name: "lending", - disabled: false, - }, - { - label: "Helpdesk", - value: "helpdesk", - name: "helpdesk", - disabled: false, - }, - { - label: "Pibicut", - value: "pibicut", - name: "pibicut", - disabled: false, - }, - { - label: "PDF on Submit", - value: "pdf_on_submit", - name: "pdf_on_submit", - disabled: false, - }, - { - label: "Insights", - value: "insights", - name: "insights", - disabled: false, - }, - { - label: "Jobcard Planning", - value: "jobcard_planning", - name: "jobcard_planning", - disabled: false, - }, - { label: "Marley", value: "marley", name: "marley", disabled: false }, - { label: "Raven", value: "raven", name: "raven", disabled: false }, - { label: "CRM", value: "crm", name: "crm", disabled: false }, - { - label: "Builder", - value: "builder", - name: "builder", - disabled: false, - }, - { - label: "Check Run", - value: "check_run", - name: "check_run", - disabled: false, - }, - { - label: "Inventory Tools", - value: "inventory_tools", - name: "inventory_tools", - disabled: false, - }, - { - label: "Employee Self Service", - value: "employee_self_service", - name: "employee_self_service", - disabled: false, - }, - { - label: "Expenses", - value: "erpnext-expense-management-module", - name: "erpnext-expense-management-module", - disabled: false, - }, - { - label: "QR Code", - value: "Frappe-QR-Code", - name: "Frappe-QR-Code", - disabled: false, - }, - { label: "Drive", value: "drive", name: "drive", disabled: false }, - { - label: "POS Awesome", - value: "posawesome", - name: "posawesome", - disabled: false, - }, - { label: "PropMS", value: "PropMS", name: "PropMS", disabled: false }, - { label: "Etims", value: "Etims", name: "Etims", disabled: false }, - { - label: "Utility Billing", - value: "utility-billing", - name: "utility-billing", - disabled: false, - }, - { - label: "PibiCal", - value: "pibical", - name: "pibical", - disabled: false, - }, - { - label: "Junior School", - value: "Junior-School", - name: "Junior-School", - disabled: false, - }, - { - label: "KE Compliance", - value: "kenya_compliance_via_slade", - name: "kenya_compliance_via_slade", - disabled: false, - }, - { - label: "ProjectIT", - value: "ProjectIT", - name: "ProjectIT", - disabled: false, - }, - { - label: "Whitelabel", - value: "whitelabel", - name: "whitelabel", - disabled: false, - }, - { - label: "SMPP Gateway", - value: "smpp_gateway", - name: "smpp_gateway", - disabled: false, - }, - { label: "URY", value: "ury", name: "ury", disabled: false }, - { - label: "Nex Bridge", - value: "nex_bridge", - name: "nex_bridge", - disabled: false, - }, - { - label: "POS Next", - value: "pos_next", - name: "pos_next", - disabled: false, - }, - ], erpSelectedModules: [], + podmanImages: [], + app_json: "", + appJsonError: "", + frappeVersion: "version-15", + toggleAccordion: [false], + isAddAppModalOpen: false, + editingAppIndex: null, + newApp: { + app_name: "", + url: "", + branch: "", + labels: "", + }, + newAppErrors: { + app_name: "", + url: "", + }, loading: { getConfiguration: false, configureModule: false, + buildDockerImage: false, + getPodmanImages: false, }, error: { getConfiguration: "", @@ -386,14 +421,58 @@ export default { host: "", lets_encrypt: "", http2https: "", + getPodmanImages: "", }, }; }, computed: { ...mapState(["instanceName", "core", "appName"]), + parsedApps() { + if (!this.app_json) { + return []; + } + try { + const apps = JSON.parse(this.app_json); + if (!Array.isArray(apps)) { + return []; + } + return apps; + } catch (e) { + return []; + } + }, + erpNextModules() { + if (!this.app_json) { + return []; + } + try { + const apps = JSON.parse(this.app_json); + if (!Array.isArray(apps)) { + return []; + } + return apps.map((app) => { + let label = app.app_name || app.name; + if (!label && app.url) { + const urlParts = app.url.split("/"); + label = urlParts[urlParts.length - 1] || "Unknown"; + } else if (!label) { + label = "Unknown"; + } + return { + label: label, + value: app.app_name || app.name || label, + name: app.app_name || app.name || label, + disabled: false, + }; + }); + } catch (e) { + return []; + } + }, }, created() { this.getConfiguration(); + this.getPodmanImages(); }, beforeRouteEnter(to, from, next) { next((vm) => { @@ -406,6 +485,188 @@ export default { next(); }, methods: { + async getPodmanImages() { + this.loading.getPodmanImages = true; + this.error.getPodmanImages = ""; + const taskAction = "podman-images-module"; + const eventId = this.getUuid(); + + // register to task error + this.core.$root.$once( + `${taskAction}-aborted-${eventId}`, + this.getPodmanImagesAborted + ); + + // register to task completion + this.core.$root.$once( + `${taskAction}-completed-${eventId}`, + this.getPodmanImagesCompleted + ); + + const res = await to( + this.createModuleTaskForApp(this.instanceName, { + action: taskAction, + extra: { + title: this.$t("action." + taskAction), + isNotificationHidden: true, + eventId, + }, + }) + ); + const err = res[0]; + + if (err) { + console.error(`error creating task ${taskAction}`, err); + this.error.getPodmanImages = this.getErrorMessage(err); + this.loading.getPodmanImages = false; + return; + } + }, + getPodmanImagesAborted(taskResult, taskContext) { + console.error(`${taskContext.action} aborted`, taskResult); + this.error.getPodmanImages = this.$t("error.generic_error"); + this.loading.getPodmanImages = false; + }, + getPodmanImagesCompleted(taskContext, taskResult) { + const imagesData = taskResult.output; + this.podmanImages = (imagesData.images || []).map((image) => ({ + id: image.Id, + repositories: image.Names || [], + tags: image.Names + ? image.Names.map((name) => { + const parts = name.split(":"); + return parts.length > 1 ? parts[parts.length - 1] : "latest"; + }) + : [], + created: image.CreatedAt, + size: this.formatFileSize(image.VirtualSize), + })); + if (imagesData.error) { + this.error.getPodmanImages = imagesData.error; + } + this.loading.getPodmanImages = false; + }, + openAddAppModal() { + this.editingAppIndex = null; + this.newApp = { + app_name: "", + url: "", + branch: "", + labels: "", + }; + this.newAppErrors = { + app_name: "", + url: "", + }; + this.isAddAppModalOpen = true; + }, + editApp(index) { + this.editingAppIndex = index; + const app = this.parsedApps[index]; + this.newApp = { + app_name: app.app_name || app.name || "", + url: app.url || "", + branch: app.branch || "", + labels: app.labels || "", + }; + this.newAppErrors = { + app_name: "", + url: "", + }; + this.isAddAppModalOpen = true; + }, + closeAddAppModal() { + this.isAddAppModalOpen = false; + this.editingAppIndex = null; + }, + addApp() { + this.newAppErrors.app_name = ""; + this.newAppErrors.url = ""; + let isValid = true; + + if (!this.newApp.app_name) { + this.newAppErrors.app_name = "App name is required"; + isValid = false; + } + if (!this.newApp.url) { + this.newAppErrors.url = "Repository URL is required"; + isValid = false; + } + + if (!isValid) { + return; + } + + try { + let currentApps = []; + if (this.app_json) { + currentApps = JSON.parse(this.app_json); + if (!Array.isArray(currentApps)) { + currentApps = []; + } + } + + const appData = { + app_name: this.newApp.app_name, + url: this.newApp.url, + branch: this.newApp.branch || "main", + labels: this.newApp.labels || "", + }; + + if (this.editingAppIndex !== null) { + // Update existing app + currentApps[this.editingAppIndex] = appData; + } else { + // Add new app + currentApps.push(appData); + } + + this.app_json = JSON.stringify(currentApps, null, 2); + this.parseAppJson(); + this.closeAddAppModal(); + } catch (e) { + console.error("Error saving app:", e); + } + }, + removeApp(index) { + try { + let currentApps = JSON.parse(this.app_json); + if (Array.isArray(currentApps)) { + currentApps.splice(index, 1); + this.app_json = JSON.stringify(currentApps, null, 2); + this.parseAppJson(); + } + } catch (e) { + console.error("Error removing app:", e); + } + }, + copyJsonToClipboard() { + navigator.clipboard.writeText(this.app_json).then(() => { + alert("JSON copied to clipboard!"); + }); + }, + parseAppJson() { + this.appJsonError = ""; + if (!this.app_json) { + this.erpSelectedModules = []; + return; + } + try { + const apps = JSON.parse(this.app_json); + if (!Array.isArray(apps)) { + this.appJsonError = this.$t("settings.app_json_must_be_array"); + return; + } + const moduleNames = apps + .map((app) => app.app_name || app.name) + .filter((name) => name); + this.erpSelectedModules = this.erpSelectedModules.filter((m) => + moduleNames.includes(m) + ); + } catch (e) { + this.appJsonError = this.$t("settings.invalid_json_format"); + } + }, async getConfiguration() { this.loading.getConfiguration = true; this.error.getConfiguration = ""; @@ -415,13 +676,13 @@ export default { // register to task error this.core.$root.$once( `${taskAction}-aborted-${eventId}`, - this.getConfigurationAborted, + this.getConfigurationAborted ); // register to task completion this.core.$root.$once( `${taskAction}-completed-${eventId}`, - this.getConfigurationCompleted, + this.getConfigurationCompleted ); const res = await to( @@ -432,7 +693,7 @@ export default { isNotificationHidden: true, eventId, }, - }), + }) ); const err = res[0]; @@ -453,9 +714,13 @@ export default { this.host = config.host; this.isLetsEncryptEnabled = config.lets_encrypt; this.isHttpToHttpsEnabled = config.http2https; - this.erpSelectedModules = config.erpSelectedModules; this.hasBackup = config.hasBackup; - console.log("Has Backup: " + this.hasBackup); + this.frappeVersion = config.frappeVersion || "version-15"; + this.app_json = atob(config.appJson); + + this.$nextTick(() => { + this.erpSelectedModules = config.erpSelectedModules || []; + }); this.loading.getConfiguration = false; this.focusElement("host"); @@ -495,19 +760,19 @@ export default { // register to task error this.core.$root.$once( `${taskAction}-aborted-${eventId}`, - this.configureModuleAborted, + this.configureModuleAborted ); // register to task validation this.core.$root.$once( `${taskAction}-validation-failed-${eventId}`, - this.configureModuleValidationFailed, + this.configureModuleValidationFailed ); // register to task completion this.core.$root.$once( `${taskAction}-completed-${eventId}`, - this.configureModuleCompleted, + this.configureModuleCompleted ); const res = await to( this.createModuleTaskForApp(this.instanceName, { @@ -525,7 +790,7 @@ export default { description: this.$t("settings.configuring"), eventId, }, - }), + }) ); const err = res[0]; @@ -542,19 +807,19 @@ export default { // register to task error this.core.$root.$once( `${taskAction}-aborted-${eventId}`, - this.configureModuleAborted, + this.configureModuleAborted ); // register to task validation this.core.$root.$once( `${taskAction}-validation-failed-${eventId}`, - this.configureModuleValidationFailed, + this.configureModuleValidationFailed ); // register to task completion this.core.$root.$once( `${taskAction}-completed-${eventId}`, - this.configureModuleCompleted, + this.configureModuleCompleted ); const res = await to( this.createModuleTaskForApp(this.instanceName, { @@ -572,7 +837,7 @@ export default { description: this.$t("settings.configuring"), eventId, }, - }), + }) ); const err = res[0]; @@ -599,19 +864,19 @@ export default { // register to task error this.core.$root.$once( `${taskAction}-aborted-${eventId}`, - this.configureModuleAborted, + this.configureModuleAborted ); // register to task validation this.core.$root.$once( `${taskAction}-validation-failed-${eventId}`, - this.configureModuleValidationFailed, + this.configureModuleValidationFailed ); // register to task completion this.core.$root.$once( `${taskAction}-completed-${eventId}`, - this.configureModuleCompleted, + this.configureModuleCompleted ); const res = await to( this.createModuleTaskForApp(this.instanceName, { @@ -621,6 +886,8 @@ export default { lets_encrypt: this.isLetsEncryptEnabled, http2https: this.isHttpToHttpsEnabled, erpSelectedModules: this.erpSelectedModules, + appJson: btoa(this.app_json), + frappeVersion: this.frappeVersion, }, extra: { title: this.$t("settings.instance_configuration", { @@ -629,7 +896,7 @@ export default { description: this.$t("settings.configuring"), eventId, }, - }), + }) ); const err = res[0]; @@ -651,6 +918,55 @@ export default { // reload configuration this.getConfiguration(); }, + async buildDockerImage() { + const taskAction = "build-docker-image"; + const eventId = this.getUuid(); + this.loading.buildDockerImage = true; + + // register to task error + this.core.$root.$once( + `${taskAction}-aborted-${eventId}`, + this.buildDockerImageAborted + ); + + // register to task completion + this.core.$root.$once( + `${taskAction}-completed-${eventId}`, + this.buildDockerImageCompleted + ); + const res = await to( + this.createModuleTaskForApp(this.instanceName, { + action: taskAction, + extra: { + title: this.$t("settings.instance_configuration", { + instance: this.instanceName, + }), + description: this.$t("settings.configuring"), + eventId, + }, + }) + ); + const err = res[0]; + if (err) { + console.error(`error creating task ${taskAction}`, err); + return; + } + }, + buildDockerImageAborted(taskResult, taskContext) { + this.loading.buildDockerImage = false; + console.log(`${taskContext.icon} aborted`, taskResult); + }, + buildDockerImageCompleted() { + this.loading.buildDockerImage = false; + this.getConfiguration(); + }, + formatFileSize(bytes) { + if (bytes === 0) return "0 Bytes"; + const k = 1024; + const sizes = ["Bytes", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; + }, }, }; @@ -664,4 +980,60 @@ export default { .maxwidth { max-width: 38rem; } + +.app-json-error { + margin-bottom: $spacing-06; +} + +.apps-container { + display: flex; + flex-direction: column; + gap: $spacing-06; +} + +.apps-list { + max-height: 300px; + overflow-y: auto; +} + +.empty-state { + padding: $spacing-06; + text-align: center; + color: $text-02; +} + +.apps-actions { + display: flex; + gap: $spacing-04; + align-items: center; +} + +.copy-json-btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.images-container { + max-height: 400px; + overflow-y: auto; + margin-bottom: $spacing-06; +} + +.images-actions { + display: flex; + gap: $spacing-04; + align-items: center; +} + +.error-section { + margin-bottom: $spacing-06; +} + +.loading-section { + display: flex; + justify-content: center; + align-items: center; + padding: $spacing-06; +}