From d76b2f2a323dd7a6b5be7761fad28e9035684f34 Mon Sep 17 00:00:00 2001 From: kevin Date: Mon, 15 Jan 2024 15:02:41 -0500 Subject: [PATCH 1/6] Global .env page and getting ports from running container --- .../socket-handlers/main-socket-handler.ts | 18 +++ backend/stack.ts | 52 ++++++--- common/util-common.ts | 13 ++- frontend/components.d.ts | 1 + frontend/src/components/Container.vue | 6 +- .../src/components/settings/GlobalEnv.vue | 109 ++++++++++++++++++ frontend/src/lang/en.json | 3 +- frontend/src/pages/Compose.vue | 3 +- frontend/src/pages/Settings.vue | 3 + frontend/src/router.ts | 5 + 10 files changed, 194 insertions(+), 19 deletions(-) create mode 100644 frontend/src/components/settings/GlobalEnv.vue diff --git a/backend/socket-handlers/main-socket-handler.ts b/backend/socket-handlers/main-socket-handler.ts index 5d31878a..ab884376 100644 --- a/backend/socket-handlers/main-socket-handler.ts +++ b/backend/socket-handlers/main-socket-handler.ts @@ -18,6 +18,8 @@ import { import { passwordStrength } from "check-password-strength"; import jwt from "jsonwebtoken"; import { Settings } from "../settings"; +import fs, { promises as fsAsync } from "fs"; +import path from "path"; export class MainSocketHandler extends SocketHandler { create(socket : DockgeSocket, server : DockgeServer) { @@ -242,6 +244,12 @@ export class MainSocketHandler extends SocketHandler { checkLogin(socket); const data = await Settings.getSettings("general"); + if (fs.existsSync(path.join(server.stacksDir, "global.env"))) { + data.globalENV = fs.readFileSync(path.join(server.stacksDir, "global.env"), "utf-8"); + } else { + data.globalENV = "# VARIABLE=value #comment"; + } + callback({ ok: true, data: data, @@ -270,6 +278,16 @@ export class MainSocketHandler extends SocketHandler { if (!currentDisabledAuth && data.disableAuth) { await doubleCheckPassword(socket, currentPassword); } + // Handle global.env + if (data.globalENV && data.globalENV != "# VARIABLE=value #comment") { + await fsAsync.writeFile(path.join(server.stacksDir, "global.env"), data.globalENV); + } else { + await fsAsync.rm(path.join(server.stacksDir, "global.env"), { + recursive: true, + force: true + }); + } + delete data.globalENV; await Settings.setSettings("general", data); diff --git a/backend/stack.ts b/backend/stack.ts index fbce5002..f2176961 100644 --- a/backend/stack.ts +++ b/backend/stack.ts @@ -93,7 +93,7 @@ export class Stack { * Get the status of the stack from `docker compose ps --format json` */ async ps() : Promise { - let res = await childProcessAsync.spawn("docker", [ "compose", "ps", "--format", "json" ], { + let res = await childProcessAsync.spawn("docker", this.getComposeOptions("ps", "--format", "json"), { cwd: this.path, encoding: "utf-8", }); @@ -208,7 +208,7 @@ export class Stack { async deploy(socket : DockgeSocket) : Promise { const terminalName = getComposeTerminalName(socket.endpoint, this.name); - let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "up", "-d", "--remove-orphans" ], this.path); + let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", this.getComposeOptions("up", "-d", "--remove-orphans"), this.path); if (exitCode !== 0) { throw new Error("Failed to deploy, please check the terminal output for more information."); } @@ -217,7 +217,7 @@ export class Stack { async delete(socket: DockgeSocket) : Promise { const terminalName = getComposeTerminalName(socket.endpoint, this.name); - let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "down", "--remove-orphans" ], this.path); + let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", this.getComposeOptions("down", "--remove-orphans"), this.path); if (exitCode !== 0) { throw new Error("Failed to delete, please check the terminal output for more information."); } @@ -407,9 +407,22 @@ export class Stack { return stack; } + getComposeOptions(command : string, ...extraOptions : string[]) { + //--env-file ./../global.env --env-file .env + let options = [ "compose", command, ...extraOptions ]; + if (fs.existsSync(path.join(this.server.stacksDir, "global.env"))) { + if (fs.existsSync(path.join(this.path, ".env"))) { + options.splice(1, 0, "--env-file", "./.env"); + } + options.splice(1, 0, "--env-file", "../global.env"); + } + console.log(options); + return options; + } + async start(socket: DockgeSocket) { const terminalName = getComposeTerminalName(socket.endpoint, this.name); - let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "up", "-d", "--remove-orphans" ], this.path); + let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", this.getComposeOptions("up", "-d", "--remove-orphans"), this.path); if (exitCode !== 0) { throw new Error("Failed to start, please check the terminal output for more information."); } @@ -418,7 +431,7 @@ export class Stack { async stop(socket: DockgeSocket) : Promise { const terminalName = getComposeTerminalName(socket.endpoint, this.name); - let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "stop" ], this.path); + let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", this.getComposeOptions("stop"), this.path); if (exitCode !== 0) { throw new Error("Failed to stop, please check the terminal output for more information."); } @@ -427,7 +440,7 @@ export class Stack { async restart(socket: DockgeSocket) : Promise { const terminalName = getComposeTerminalName(socket.endpoint, this.name); - let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "restart" ], this.path); + let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", this.getComposeOptions("restart"), this.path); if (exitCode !== 0) { throw new Error("Failed to restart, please check the terminal output for more information."); } @@ -436,7 +449,7 @@ export class Stack { async down(socket: DockgeSocket) : Promise { const terminalName = getComposeTerminalName(socket.endpoint, this.name); - let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "down" ], this.path); + let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", this.getComposeOptions("down"), this.path); if (exitCode !== 0) { throw new Error("Failed to down, please check the terminal output for more information."); } @@ -445,7 +458,7 @@ export class Stack { async update(socket: DockgeSocket) { const terminalName = getComposeTerminalName(socket.endpoint, this.name); - let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "pull" ], this.path); + let exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", this.getComposeOptions("pull"), this.path); if (exitCode !== 0) { throw new Error("Failed to pull, please check the terminal output for more information."); } @@ -457,7 +470,7 @@ export class Stack { return exitCode; } - exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", [ "compose", "up", "-d", "--remove-orphans" ], this.path); + exitCode = await Terminal.exec(this.server, socket, terminalName, "docker", this.getComposeOptions("up", "-d", "--remove-orphans"), this.path); if (exitCode !== 0) { throw new Error("Failed to restart, please check the terminal output for more information."); } @@ -466,7 +479,7 @@ export class Stack { async joinCombinedTerminal(socket: DockgeSocket) { const terminalName = getCombinedTerminalName(socket.endpoint, this.name); - const terminal = Terminal.getOrCreateTerminal(this.server, terminalName, "docker", [ "compose", "logs", "-f", "--tail", "100" ], this.path); + const terminal = Terminal.getOrCreateTerminal(this.server, terminalName, "docker", this.getComposeOptions("logs", "-f", "--tail", "100"), this.path); terminal.enableKeepAlive = true; terminal.rows = COMBINED_TERMINAL_ROWS; terminal.cols = COMBINED_TERMINAL_COLS; @@ -487,7 +500,7 @@ export class Stack { let terminal = Terminal.getTerminal(terminalName); if (!terminal) { - terminal = new InteractiveTerminal(this.server, terminalName, "docker", [ "compose", "exec", serviceName, shell ], this.path); + terminal = new InteractiveTerminal(this.server, terminalName, "docker", this.getComposeOptions("exec", serviceName, shell), this.path); terminal.rows = TERMINAL_ROWS; log.debug("joinContainerTerminal", "Terminal created"); } @@ -497,10 +510,10 @@ export class Stack { } async getServiceStatusList() { - let statusList = new Map(); + let statusList = new Map(); try { - let res = await childProcessAsync.spawn("docker", [ "compose", "ps", "--format", "json" ], { + let res = await childProcessAsync.spawn("docker", this.getComposeOptions("ps", "--format", "json"), { cwd: this.path, encoding: "utf-8", }); @@ -514,10 +527,19 @@ export class Stack { for (let line of lines) { try { let obj = JSON.parse(line); + let ports = (obj.Ports as string).split(/,\s*/).filter((s) => { + return s.indexOf("->") >= 0; + }); if (obj.Health === "") { - statusList.set(obj.Service, obj.State); + statusList.set(obj.Service, { + state: obj.State, + ports: ports + }); } else { - statusList.set(obj.Service, obj.Health); + statusList.set(obj.Service, { + state: obj.Health, + ports: ports + }); } } catch (e) { } diff --git a/common/util-common.ts b/common/util-common.ts index 587e6dd2..84bdc667 100644 --- a/common/util-common.ts +++ b/common/util-common.ts @@ -289,6 +289,7 @@ function copyYAMLCommentsItems(items : any, srcItems : any) { * - "8000-9000:80" * - "127.0.0.1:8001:8001" * - "127.0.0.1:5000-5010:5000-5010" + * - "0.0.0.0:8080->8080/tcp" * - "6060:6060/udp" * @param input * @param hostname @@ -298,9 +299,19 @@ export function parseDockerPort(input : string, hostname : string) { let display; const parts = input.split("/"); - const part1 = parts[0]; + let part1 = parts[0]; let protocol = parts[1] || "tcp"; + // coming from docker ps, split host part + const arrow = part1.indexOf("->"); + if (arrow >= 0) { + part1 = part1.split("->")[0]; + const colon = part1.indexOf(":"); + if (colon >= 0) { + part1 = part1.split(":")[1]; + } + } + // Split the last ":" const lastColon = part1.lastIndexOf(":"); diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 708dd4e0..300dbd7d 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -17,6 +17,7 @@ declare module 'vue' { Confirm: typeof import('./src/components/Confirm.vue')['default'] Container: typeof import('./src/components/Container.vue')['default'] General: typeof import('./src/components/settings/General.vue')['default'] + GlobalEnv: typeof import('./src/components/settings/GlobalEnv.vue')['default'] HiddenInput: typeof import('./src/components/HiddenInput.vue')['default'] Login: typeof import('./src/components/Login.vue')['default'] NetworkInput: typeof import('./src/components/NetworkInput.vue')['default'] diff --git a/frontend/src/components/Container.vue b/frontend/src/components/Container.vue index 12f77096..b0be3648 100644 --- a/frontend/src/components/Container.vue +++ b/frontend/src/components/Container.vue @@ -9,7 +9,7 @@ @@ -159,6 +159,10 @@ export default defineComponent({ status: { type: String, default: "N/A", + }, + ports: { + type: Array, + default: null } }, emits: [ diff --git a/frontend/src/components/settings/GlobalEnv.vue b/frontend/src/components/settings/GlobalEnv.vue new file mode 100644 index 00000000..db09dceb --- /dev/null +++ b/frontend/src/components/settings/GlobalEnv.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/frontend/src/lang/en.json b/frontend/src/lang/en.json index f05ac32a..e3d69090 100644 --- a/frontend/src/lang/en.json +++ b/frontend/src/lang/en.json @@ -111,5 +111,6 @@ "agentAddedSuccessfully": "Agent added successfully.", "agentRemovedSuccessfully": "Agent removed successfully.", "removeAgent": "Remove Agent", - "removeAgentMsg": "Are you sure you want to remove this agent?" + "removeAgentMsg": "Are you sure you want to remove this agent?", + "GlobalEnv": "Global .env" } diff --git a/frontend/src/pages/Compose.vue b/frontend/src/pages/Compose.vue index 7d378543..a5d1cf3a 100644 --- a/frontend/src/pages/Compose.vue +++ b/frontend/src/pages/Compose.vue @@ -128,7 +128,8 @@ :name="name" :is-edit-mode="isEditMode" :first="name === Object.keys(jsonConfig.services)[0]" - :status="serviceStatusList[name]" + :status="serviceStatusList[name]?.state" + :ports="serviceStatusList[name]?.ports" /> diff --git a/frontend/src/pages/Settings.vue b/frontend/src/pages/Settings.vue index 82431bef..47b93303 100644 --- a/frontend/src/pages/Settings.vue +++ b/frontend/src/pages/Settings.vue @@ -83,6 +83,9 @@ export default { security: { title: this.$t("Security"), }, + globalEnv: { + title: this.$t("GlobalEnv"), + }, about: { title: this.$t("About"), }, diff --git a/frontend/src/router.ts b/frontend/src/router.ts index f3db7a6b..f620f5f0 100644 --- a/frontend/src/router.ts +++ b/frontend/src/router.ts @@ -14,6 +14,7 @@ const Settings = () => import("./pages/Settings.vue"); import Appearance from "./components/settings/Appearance.vue"; import General from "./components/settings/General.vue"; const Security = () => import("./components/settings/Security.vue"); +const GlobalEnv = () => import("./components/settings/GlobalEnv.vue"); import About from "./components/settings/About.vue"; const routes = [ @@ -78,6 +79,10 @@ const routes = [ path: "security", component: Security, }, + { + path: "globalEnv", + component: GlobalEnv, + }, { path: "about", component: About, From 6e7a3830b3598e1612df2607f06b337047defd48 Mon Sep 17 00:00:00 2001 From: Paco Culebras <69261057+pacoculebras@users.noreply.github.com> Date: Wed, 17 Jan 2024 20:14:43 +0100 Subject: [PATCH 2/6] Update i18n.ts with catalan language (#377) --- frontend/src/i18n.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index b099baab..426457ed 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -30,6 +30,7 @@ const languageList = { "id": "Bahasa Indonesia (Indonesian)", "vi": "Tiếng Việt", "hu": "Magyar", + "ca": "Català", }; let messages = { From bb67969eac2decc838f305bf5c4f27e596ac8c8c Mon Sep 17 00:00:00 2001 From: Paco Culebras Date: Wed, 17 Jan 2024 16:27:12 +0000 Subject: [PATCH 3/6] Added translation using Weblate (Catalan) --- frontend/src/lang/ca.json | 1 + 1 file changed, 1 insertion(+) create mode 100644 frontend/src/lang/ca.json diff --git a/frontend/src/lang/ca.json b/frontend/src/lang/ca.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/frontend/src/lang/ca.json @@ -0,0 +1 @@ +{} From 19b060b81cc42d0a9dfcb98a82d73a54f3a1b663 Mon Sep 17 00:00:00 2001 From: Paco Culebras Date: Wed, 17 Jan 2024 16:28:21 +0000 Subject: [PATCH 4/6] Translated using Weblate (Catalan) Currently translated at 100.0% (113 of 113 strings) Translation: Dockge/dockge Translate-URL: https://weblate.kuma.pet/projects/dockge/dockge/ca/ --- frontend/src/lang/ca.json | 116 +++++++++++++++++++++++++++++++++++++- 1 file changed, 115 insertions(+), 1 deletion(-) diff --git a/frontend/src/lang/ca.json b/frontend/src/lang/ca.json index 0967ef42..71f46fab 100644 --- a/frontend/src/lang/ca.json +++ b/frontend/src/lang/ca.json @@ -1 +1,115 @@ -{} +{ + "Create your admin account": "Crea el teu compte d'administrador", + "Repeat Password": "Repeteix la contrasenya", + "Create": "Crea", + "signedInDisp": "S'ha iniciat sessió com a {0}", + "home": "Inici", + "console": "Consola", + "registry": "Registre", + "compose": "Compondre", + "addFirstStackMsg": "Compondre la teva primera pila!", + "stackName": "Nom de la pila", + "deployStack": "Desplegar", + "deleteStack": "Eliminar", + "stopStack": "Aturar", + "restartStack": "Reiniciar", + "updateStack": "Actualitzar", + "startStack": "Inicia", + "downStack": "Atura i inactiva", + "languageName": "Català", + "authIncorrectCreds": "Usuari o contrasenya incorrecte.", + "PasswordsDoNotMatch": "Les contrasenyes no coincideixen.", + "signedInDispDisabled": "Autenticació deshabilitada.", + "discardStack": "Descartar", + "saveStackDraft": "Guardar", + "notAvailableShort": "N/D", + "primaryHostname": "Nom del host primari", + "general": "General", + "container": "Contenidor | Contenidors", + "scanFolder": "Escaneja la carpeta de piles", + "dockerImage": "Imatge", + "restartPolicyAlways": "Sempre", + "restartPolicyOnFailure": "En cas de fallada", + "restartPolicyNo": "No", + "environmentVariable": "Variable d'entorn | Variables d'entorn", + "restartPolicy": "Política de reinici", + "containerName": "Nom del contenidor", + "port": "Port | Ports", + "volume": "Volum | Volums", + "network": "Xarxa | Xarxes", + "addListItem": "Afegir {0}", + "deleteContainer": "Eliminar", + "addContainer": "Afegir contenidor", + "addNetwork": "Afegir xarxa", + "passwordNotMatchMsg": "La contrasenya repetida no coincideix.", + "autoGet": "Obtenir automàticament", + "add": "Afegir", + "Edit": "Editar", + "applyToYAML": "Aplicar a YAML", + "createExternalNetwork": "Crear", + "addInternalNetwork": "Afegir", + "Save": "Guardar", + "Language": "Idioma", + "Current User": "Usuari actual", + "Change Password": "Canviar la contrasenya", + "Current Password": "Contrasenya actual", + "New Password": "Nova contrasenya", + "stackNotManagedByDockgeMsg": "Aquesta pila no està gestionada per Dockge.", + "Update Password": "Actualitzar contrasenya", + "Advanced": "Avançat", + "Disable Auth": "Deshabilitar autenticació", + "Leave": "Sortir", + "Frontend Version": "Versió del frontend", + "Check Update On GitHub": "Comprova les actualitzacions a GitHub", + "Show update if available": "Mostra si hi ha disponible una nova actualització", + "Also check beta release": "Comprovar també la versió beta", + "Remember me": "Recorda'm", + "Login": "Inici de sesió", + "Username": "Usuari", + "Settings": "Configuració", + "Logout": "Tanca sessió", + "Lowercase only": "Només minúscules", + "Convert to Compose": "Convertir a Compose", + "Docker Run": "Executar Docker", + "active": "actiu", + "exited": "finalitzat", + "inactive": "inactiu", + "Appearance": "Aparença", + "Security": "Seguretat", + "About": "Sobre", + "Allowed commands:": "Comandes permeses:", + "Internal Networks": "Xarxes internes", + "External Networks": "Xarxes externes", + "No External Networks": "No hi ha xarxes externes", + "reverseProxyMsg1": "Estàs fent servir un proxy invers?", + "reverseProxyMsg2": "Comproveu com configurar-lo per a WebSocket", + "Cannot connect to the socket server.": "No es pot connectar al servidor del socket.", + "reconnecting...": "S'està tornant a connectar…", + "connecting...": "S'està connectant al servidor del socket…", + "url": "URL | URLs", + "extra": "Extra", + "newUpdate": "Nova actualització", + "dockgeAgent": "Agent Dockge | Agents Dockge", + "currentEndpoint": "Actual", + "dockgeURL": "URL de Dockge (ex. http://127.0.0.1:5001)", + "agentOnline": "En línia", + "agentOffline": "Fora de línia", + "connecting": "Connectant", + "connect": "Connectar", + "addAgent": "Afegir agent", + "agentAddedSuccessfully": "Agent afegit correctament.", + "agentRemovedSuccessfully": "Agent eliminat correctament.", + "removeAgent": "Eliminar agent", + "removeAgentMsg": "Esteu segur que voleu eliminar aquest agent?", + "editStack": "Editar", + "deleteStackMsg": "Estàs segur que vols eliminar aquesta pila?", + "restartPolicyUnlessStopped": "A menys que s'aturi", + "dependsOn": "Dependència del contenidor | Dependències del contenidor", + "disableauth.message1": "Esteu segur que voleu desactivar l'autenticació?", + "disableauth.message2": "Està dissenyat per a escenaris on voleu implementar l'autenticació de tercers per davant de Dockge, com ara Cloudflare Access, Authelia o altres mecanismes d'autenticació.", + "Repeat New Password": "Repetiu la nova contrasenya", + "Please use this option carefully!": "Si us plau, utilitzeu aquesta opció amb cura!", + "Enable Auth": "Habilitar autenticació", + "I understand, please disable": "Ho entenc, si us plau deshabilita", + "Password": "Contrasenya" +} From 58d345c93b11178177e221f8b9e7e6f8938a7522 Mon Sep 17 00:00:00 2001 From: Cyril59310 <70776486+cyril59310@users.noreply.github.com> Date: Wed, 17 Jan 2024 20:36:28 +0100 Subject: [PATCH 5/6] Add translate key (#368) --- frontend/src/components/ArrayInput.vue | 2 +- frontend/src/lang/en.json | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/ArrayInput.vue b/frontend/src/components/ArrayInput.vue index 09587c9d..d63206b0 100644 --- a/frontend/src/components/ArrayInput.vue +++ b/frontend/src/components/ArrayInput.vue @@ -11,7 +11,7 @@
- Long syntax is not supported here. Please use the YAML editor. + {{ $t("LongSyntaxNotSupported") }}
diff --git a/frontend/src/lang/en.json b/frontend/src/lang/en.json index e3d69090..2bc59198 100644 --- a/frontend/src/lang/en.json +++ b/frontend/src/lang/en.json @@ -112,5 +112,6 @@ "agentRemovedSuccessfully": "Agent removed successfully.", "removeAgent": "Remove Agent", "removeAgentMsg": "Are you sure you want to remove this agent?", - "GlobalEnv": "Global .env" + "GlobalEnv": "Global .env", + "LongSyntaxNotSupported": "Long syntax is not supported here. Please use the YAML editor." } From 504f2d6093acb6fa726a95e819312ecde6e66452 Mon Sep 17 00:00:00 2001 From: Louis Lam Date: Fri, 19 Jan 2024 02:13:43 +0800 Subject: [PATCH 6/6] Workaround fix for tsx issue (#380) --- docker/Dockerfile | 2 +- extra/clean-tsx-tmp.js | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) create mode 100644 extra/clean-tsx-tmp.js diff --git a/docker/Dockerfile b/docker/Dockerfile index ce830598..2e167a8b 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -26,7 +26,7 @@ VOLUME /app/data EXPOSE 5001 HEALTHCHECK --interval=60s --timeout=30s --start-period=60s --retries=5 CMD extra/healthcheck ENTRYPOINT ["/usr/bin/dumb-init", "--"] -CMD ["tsx", "./backend/index.ts"] +CMD ["bash", "-c", "node ./extra/clean-tsx-tmp.js && tsx ./backend/index.ts"] ############################################ # Mark as Nightly diff --git a/extra/clean-tsx-tmp.js b/extra/clean-tsx-tmp.js new file mode 100644 index 00000000..4e2e35ba --- /dev/null +++ b/extra/clean-tsx-tmp.js @@ -0,0 +1,13 @@ +/* + * This script is used to clean up the tmp directory. + * A workaround for https://github.com/louislam/dockge/issues/353 + */ +import * as fs from "fs"; + +try { + fs.rmSync("/tmp/tsx-0", { + recursive: true, + }); +} catch (e) { + +}