diff --git a/web/src/core/tools/nestObject.ts b/web/src/core/tools/nestObject.ts new file mode 100644 index 000000000..a2127c10f --- /dev/null +++ b/web/src/core/tools/nestObject.ts @@ -0,0 +1,44 @@ +/** + * Transforms a flat object with dot-separated keys into a nested object. + * + * @param {Record} input - The flat object to be nested. + * @returns {Record} A new object with nested properties. + * + * @example + * const flatObject = { + * "foo.bar": 1, + * "foo.baz": "okay", + * "cool": "yes" + * }; + * const nestedObject = nestObject(flatObject); + * // Output will be: + * // { + * // "foo": { + * // "bar": 1, + * // "baz": "okay" + * // }, + * // "cool": "yes" + * // } + */ +export function nestObject(input: Record): Record { + const output: Record = {}; + + for (const [key, value] of Object.entries(input)) { + let parts = key.split("."); + let target = output; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + if (i === parts.length - 1) { + target[part] = value; + } else { + if (!target[part] || typeof target[part] !== "object") { + target[part] = {}; + } + target = target[part]; + } + } + } + + return output; +} diff --git a/web/src/core/usecases/serviceManager/selectors.ts b/web/src/core/usecases/serviceManager/selectors.ts index 61b6a0ad2..2f16f9e7f 100644 --- a/web/src/core/usecases/serviceManager/selectors.ts +++ b/web/src/core/usecases/serviceManager/selectors.ts @@ -3,16 +3,33 @@ import { createSelector } from "@reduxjs/toolkit"; import { name, type RunningService } from "./state"; -const runningServices = (rootState: RootState): RunningService[] | undefined => { - const { runningServices } = rootState[name]; +const readyState = (rootState: RootState) => { + const state = rootState[name]; - if (runningServices === undefined) { + if (state.stateDescription !== "ready") { return undefined; } - return [...runningServices].sort((a, b) => b.startedAt - a.startedAt); + return state; }; +const runningServices = createSelector( + readyState, + (state): RunningService[] | undefined => { + if (state === undefined) { + return undefined; + } + + const { runningServices } = state; + + if (runningServices === undefined) { + return undefined; + } + + return [...runningServices].sort((a, b) => b.startedAt - a.startedAt); + } +); + const isUpdating = (rootState: RootState): boolean => { const { isUpdating } = rootState[name]; return isUpdating; @@ -35,10 +52,13 @@ const isThereOwnedSharedServices = createSelector( undefined ); +const apiLogsEntries = createSelector(readyState, state => state?.apiLogsEntries ?? []); + export const selectors = { runningServices, deletableRunningServices, isUpdating, isThereNonOwnedServices, - isThereOwnedSharedServices + isThereOwnedSharedServices, + apiLogsEntries }; diff --git a/web/src/core/usecases/serviceManager/state.ts b/web/src/core/usecases/serviceManager/state.ts index 5447f18e0..83d49a016 100644 --- a/web/src/core/usecases/serviceManager/state.ts +++ b/web/src/core/usecases/serviceManager/state.ts @@ -3,10 +3,33 @@ import { createSlice } from "@reduxjs/toolkit"; import type { PayloadAction } from "@reduxjs/toolkit"; import { id } from "tsafe/id"; -type State = { - isUpdating: boolean; - runningServices: undefined | RunningService[]; -}; +import { nestObject } from "core/tools/nestObject"; +import * as yaml from "yaml"; + +export type State = State.NotInitialized | State.Ready; + +export namespace State { + export type Common = { + isUpdating: boolean; + }; + + export type NotInitialized = Common & { + stateDescription: "not initialized"; + }; + + export type Ready = Common & { + stateDescription: "ready"; + runningServices: RunningService[]; + envByServiceId: Record>; + postInstallInstructionsByServiceId: Record; + kubernetesNamespace: string; + apiLogsEntries: { + cmdId: number; + cmd: string; + resp: string | undefined; + }[]; + }; +} export type RunningService = RunningService.Owned | RunningService.NotOwned; @@ -23,8 +46,7 @@ export declare namespace RunningService { vaultTokenExpirationTime: number | undefined; s3TokenExpirationTime: number | undefined; urls: string[]; - postInstallInstructions: string | undefined; - env: Record; + hasPostInstallInstructions: boolean; }; export type Owned = Common & { @@ -43,23 +65,49 @@ export const name = "serviceManager"; export const { reducer, actions } = createSlice({ name, - "initialState": id({ - "isUpdating": false, - "runningServices": undefined - }), + "initialState": id( + id({ + "stateDescription": "not initialized", + "isUpdating": false + }) + ), "reducers": { "updateStarted": state => { state.isUpdating = true; }, "updateCompleted": ( - _state, - { payload }: PayloadAction<{ runningServices: RunningService[] }> + state, + { + payload + }: PayloadAction<{ + runningServices: RunningService[]; + envByServiceId: Record>; + postInstallInstructionsByServiceId: Record; + kubernetesNamespace: string; + }> ) => { - const { runningServices } = payload; + const { + runningServices, + envByServiceId, + postInstallInstructionsByServiceId, + kubernetesNamespace + } = payload; - return id({ + return id({ + "stateDescription": "ready", "isUpdating": false, - runningServices + runningServices, + envByServiceId, + postInstallInstructionsByServiceId, + kubernetesNamespace, + "apiLogsEntries": (() => { + switch (state.stateDescription) { + case "ready": + return state.apiLogsEntries; + case "not initialized": + return []; + } + })() }); }, "serviceStarted": ( @@ -72,6 +120,9 @@ export const { reducer, actions } = createSlice({ }> ) => { const { serviceId, doOverwriteStaredAtToNow } = payload; + + assert(state.stateDescription === "ready"); + const { runningServices } = state; assert(runningServices !== undefined); @@ -92,6 +143,8 @@ export const { reducer, actions } = createSlice({ "serviceStopped": (state, { payload }: PayloadAction<{ serviceId: string }>) => { const { serviceId } = payload; + assert(state.stateDescription === "ready"); + const { runningServices } = state; assert(runningServices !== undefined); @@ -99,6 +152,40 @@ export const { reducer, actions } = createSlice({ runningServices.findIndex(({ id }) => id === serviceId), 1 ); + }, + "postInstallInstructionsRequested": ( + state, + { payload }: { payload: { serviceId: string } } + ) => { + const { serviceId } = payload; + + assert(state.stateDescription === "ready"); + + const postInstallInstructions = + state.postInstallInstructionsByServiceId[serviceId]; + + assert(postInstallInstructions !== undefined); + + state.apiLogsEntries.push({ + "cmdId": Date.now(), + "cmd": `helm get notes ${serviceId} --namespace ${state.kubernetesNamespace}`, + "resp": postInstallInstructions + }); + }, + "envRequested": (state, { payload }: { payload: { serviceId: string } }) => { + const { serviceId } = payload; + + assert(state.stateDescription === "ready"); + + const env = state.envByServiceId[serviceId]; + + state.apiLogsEntries.push({ + "cmdId": Date.now(), + "cmd": `helm get values ${serviceId} --namespace ${state.kubernetesNamespace}`, + "resp": ["USER-SUPPLIED VALUES:", yaml.stringify(nestObject(env))].join( + "\n" + ) + }); } } }); diff --git a/web/src/core/usecases/serviceManager/thunks.ts b/web/src/core/usecases/serviceManager/thunks.ts index 14e7d4cc8..6630060aa 100644 --- a/web/src/core/usecases/serviceManager/thunks.ts +++ b/web/src/core/usecases/serviceManager/thunks.ts @@ -4,6 +4,7 @@ import * as projectConfigs from "../projectConfigs"; import type { Thunks } from "core/core"; import { exclude } from "tsafe/exclude"; import { createUsecaseContextApi } from "redux-clean-architecture"; +import { assert } from "tsafe/assert"; import { Evt } from "evt"; import { name, actions } from "./state"; import type { RunningService } from "./state"; @@ -75,16 +76,18 @@ export const thunks = { return { getLogoUrl }; })(); + const { namespace: kubernetesNamespace } = + projectConfigs.selectors.selectedProject(getState()); + const getMonitoringUrl = (params: { serviceId: string }) => { const { serviceId } = params; - const project = projectConfigs.selectors.selectedProject(getState()); - - const selectedDeploymentRegion = - deploymentRegion.selectors.selectedDeploymentRegion(getState()); + const region = deploymentRegion.selectors.selectedDeploymentRegion( + getState() + ); - return selectedDeploymentRegion.servicesMonitoringUrlPattern - ?.replace("$NAMESPACE", project.namespace) + return region.servicesMonitoringUrlPattern + ?.replace("$NAMESPACE", kubernetesNamespace) .replace("$INSTANCE", serviceId.replace(/^\//, "")); }; @@ -96,6 +99,19 @@ export const thunks = { dispatch( actions.updateCompleted({ + kubernetesNamespace, + "envByServiceId": Object.fromEntries( + runningServicesRaw.map(({ id, env }) => [id, env]) + ), + "postInstallInstructionsByServiceId": Object.fromEntries( + runningServicesRaw + .map(({ id, postInstallInstructions }) => + postInstallInstructions === undefined + ? undefined + : [id, postInstallInstructions] + ) + .filter(exclude(undefined)) + ), "runningServices": runningServicesRaw .map( ({ @@ -104,10 +120,10 @@ export const thunks = { packageName, urls, startedAt, - postInstallInstructions, isShared, - env, ownerUsername, + env, + postInstallInstructions, ...rest }) => { const common: RunningService.Common = { @@ -141,8 +157,8 @@ export const thunks = { ) ), true), - postInstallInstructions, - env + "hasPostInstallInstructions": + postInstallInstructions !== undefined }; const isOwned = ownerUsername === username; @@ -181,6 +197,41 @@ export const thunks = { dispatch(actions.serviceStopped({ serviceId })); await onyxiaApi.stopService({ serviceId }); + }, + "getPostInstallInstructions": + (params: { serviceId: string }) => + (...args): string => { + const { serviceId } = params; + + const [dispatch, getState] = args; + + const state = getState()[name]; + + assert(state.stateDescription === "ready"); + + const postInstallInstructions = + state.postInstallInstructionsByServiceId[serviceId]; + + assert(postInstallInstructions !== undefined); + + dispatch(actions.postInstallInstructionsRequested({ serviceId })); + + return postInstallInstructions; + }, + "getEnv": + (params: { serviceId: string }) => + (...args): Record => { + const { serviceId } = params; + + const [dispatch, getState] = args; + + dispatch(actions.envRequested({ serviceId })); + + const state = getState()[name]; + + assert(state.stateDescription === "ready"); + + return state.envByServiceId[serviceId]; } } satisfies Thunks; diff --git a/web/src/stories/pages/myServices/MyServicesCards/MyServicesCard/MyServicesCard.stories.tsx b/web/src/stories/pages/myServices/MyServicesCards/MyServicesCard/MyServicesCard.stories.tsx index 70768d16c..baef1cc7c 100644 --- a/web/src/stories/pages/myServices/MyServicesCards/MyServicesCard/MyServicesCard.stories.tsx +++ b/web/src/stories/pages/myServices/MyServicesCards/MyServicesCard/MyServicesCard.stories.tsx @@ -2,6 +2,7 @@ import { MyServicesCard } from "ui/pages/myServices/MyServicesCards/MyServicesCa import { sectionName } from "./sectionName"; import { getStoryFactory, logCallbacks } from "stories/getStory"; import rstudioImgUrl from "stories/assets/img/rstudio.png"; +import { Evt } from "evt"; const { meta, getStory } = getStoryFactory({ sectionName, @@ -23,11 +24,15 @@ export const ViewRegular = getStory({ "ownerUsername": "jdoe", "s3TokenExpirationTime": Date.now() + 3600 * 1000, "vaultTokenExpirationTime": Infinity, - ...logCallbacks([ - "onRequestDelete", - "onRequestShowPostInstallInstructions", - "onRequestShowEnv" - ]) + "evtAction": new Evt(), + "getEnv": () => ({ + "foo": "foo value", + "bar": "bar value", + "baz": "baz value" + }), + "getPoseInstallInstructions": () => "Post **install** instructions", + "getServicePassword": () => Promise.resolve("password"), + ...logCallbacks(["onRequestDelete"]) }); export const ViewStarting = getStory({ @@ -42,9 +47,13 @@ export const ViewStarting = getStory({ "ownerUsername": undefined, "s3TokenExpirationTime": Infinity, "vaultTokenExpirationTime": Infinity, - ...logCallbacks([ - "onRequestDelete", - "onRequestShowPostInstallInstructions", - "onRequestShowEnv" - ]) + "evtAction": new Evt(), + "getEnv": () => ({ + "foo": "foo value", + "bar": "bar value", + "baz": "baz value" + }), + "getPoseInstallInstructions": () => "Post **install** instructions", + "getServicePassword": () => Promise.resolve("password"), + ...logCallbacks(["onRequestDelete"]) }); diff --git a/web/src/stories/pages/myServices/MyServicesCards/MyServicesCards.stories.tsx b/web/src/stories/pages/myServices/MyServicesCards/MyServicesCards.stories.tsx index 8741095c7..bd3f4b64b 100644 --- a/web/src/stories/pages/myServices/MyServicesCards/MyServicesCards.stories.tsx +++ b/web/src/stories/pages/myServices/MyServicesCards/MyServicesCards.stories.tsx @@ -26,13 +26,6 @@ const props: Props = { "openUrl": url + "/" + i, "monitoringUrl": url, "startTime": Date.now(), - "postInstallInstructions": - i % 3 === 0 ? `Post install instruction ${i}` : undefined, - "env": { - "foo": "foo value", - "bar": "bar value", - "baz": "baz value" - }, ...(i % 2 === 0 ? { "isOwned": false, @@ -45,11 +38,18 @@ const props: Props = { "ownerUsername": undefined }), "vaultTokenExpirationTime": Infinity, - "s3TokenExpirationTime": Infinity + "s3TokenExpirationTime": Infinity, + "hasPostInstallInstructions": i % 3 === 0 })), "catalogExplorerLink": { "href": url, "onClick": () => {} }, "evtAction": new Evt(), "getServicePassword": () => Promise.resolve("xyz"), + "getEnv": () => ({ + "foo": "foo value", + "bar": "bar value", + "baz": "baz value" + }), + "getPostInstallInstructions": () => "Post **install** instructions", ...logCallbacks(["onRequestDelete"]) }; diff --git a/web/src/ui/i18n/resources/de.tsx b/web/src/ui/i18n/resources/de.tsx index cbb86da79..0d9d05876 100644 --- a/web/src/ui/i18n/resources/de.tsx +++ b/web/src/ui/i18n/resources/de.tsx @@ -484,20 +484,23 @@ Fühlen Sie sich frei zu erkunden und die Kontrolle über Ihre Kubernetes-Implem "saved": "Gespeichert", "show all": "Alle anzeigen" }, + "ReadmeAndEnvDialog": { + "ok": "Ok", + "return": "Zurück" + }, + "CopyOpenButton": { + "first copy the password": "Kopieren Sie zuerst das Passwort...", + "open the service": "Dienst öffnen 🚀" + }, "MyServicesCards": { - "running services": "Laufende Dienste", - "no services running": "Sie haben derzeit keine laufenden Dienste", + "running services": "Laufende Dienste" + }, + "NoRunningService": { "launch one": "Klicken Sie hier, um einen zu starten", - "ok": "ok", - "need to copy": "Müssen Sie nicht abgeschnittene Werte kopieren?", - "everything have been printed to the console": - "Alles wurde in der Konsole protokolliert", - "first copy the password": "Kopieren Sie zuerst das Passwort...", - "open the service": "Dienst öffnen 🚀", - "return": "Zurück" + "no services running": "Sie haben derzeit keine laufenden Dienste" }, "ApiLogsBar": { - "ok": "ok" + "ok": "Ok" } /* spell-checker: enable */ }; diff --git a/web/src/ui/i18n/resources/en.tsx b/web/src/ui/i18n/resources/en.tsx index 35d399309..4cece1989 100644 --- a/web/src/ui/i18n/resources/en.tsx +++ b/web/src/ui/i18n/resources/en.tsx @@ -476,18 +476,21 @@ Feel free to explore and take charge of your Kubernetes deployments! "saved": "Saved", "show all": "Show all" }, - "MyServicesCards": { - "running services": "Running services", - "no services running": "You don't have any service running", - "launch one": "Click here to launch one", + "ReadmeAndEnvDialog": { "ok": "ok", - "need to copy": "Need to copy untruncated values?", - "everything have been printed to the console": - "Everything have been printed to the console", - "first copy the password": "First, copy the service...", - "open the service": "Open the service 🚀", "return": "Return" }, + "CopyOpenButton": { + "first copy the password": "First, copy the service...", + "open the service": "Open the service 🚀" + }, + "MyServicesCards": { + "running services": "Running services" + }, + "NoRunningService": { + "launch one": "Click here to launch one", + "no services running": "You don't have any service running" + }, "ApiLogsBar": { "ok": "Ok" } diff --git a/web/src/ui/i18n/resources/fi.tsx b/web/src/ui/i18n/resources/fi.tsx index 23caa32fa..abdcfc7ad 100644 --- a/web/src/ui/i18n/resources/fi.tsx +++ b/web/src/ui/i18n/resources/fi.tsx @@ -474,17 +474,21 @@ Tunne olosi vapaaksi tutkimaan ja ottamaan haltuusi Kubernetes-levityksesi! "saved": "Tallennettu", "show all": "Näytä kaikki" }, - "MyServicesCards": { - "running services": "Käynnissä olevat palvelut", - "no services running": "Sinulla ei ole käynnissä olevia palveluita", - "launch one": "Klikkaa tästä käynnistääksesi palvelun", + "ReadmeAndEnvDialog": { "ok": "ok", - "need to copy": "Tarvitsetko kopioda rajaamattomat arvot?", - "everything have been printed to the console": "Kaikki on tulostettu konsoliin", - "first copy the password": "Kopioi ensin palvelun...", - "open the service": "Avaa palvelu 🚀", "return": "Palaa" }, + "CopyOpenButton": { + "first copy the password": "Kopioi ensin palvelun...", + "open the service": "Avaa palvelu 🚀" + }, + "MyServicesCards": { + "running services": "Käynnissä olevat palvelut" + }, + "NoRunningService": { + "launch one": "Käynnistä palvelu", + "no services running": "Sinulla ei ole käynnissä olevia palveluita" + }, "ApiLogsBar": { "ok": "ok" } diff --git a/web/src/ui/i18n/resources/fr.tsx b/web/src/ui/i18n/resources/fr.tsx index 8aca8c24c..204152211 100644 --- a/web/src/ui/i18n/resources/fr.tsx +++ b/web/src/ui/i18n/resources/fr.tsx @@ -488,18 +488,22 @@ N'hésitez pas à explorer et à prendre en main vos déploiements Kubernetes ! "saved": "Enregistrés", "show all": "Afficher tous" }, - "MyServicesCards": { - "running services": "Services en cours", - "no services running": - "Vous n'avez actuellement aucun service en cours d'exécution", - "launch one": "Cliquez ici pour en lancer un", + "ReadmeAndEnvDialog": { "ok": "ok", - "need to copy": "Besoin de copier les valeurs non tronquées ?", - "everything have been printed to the console": "Tout a été loggé dans la console", - "first copy the password": "Commencez par copier le mot de passe...", - "open the service": "Ouvrir le service 🚀", "return": "Retour" }, + "CopyOpenButton": { + "first copy the password": "Commencez par copier le mot de passe...", + "open the service": "Ouvrir le service 🚀" + }, + "MyServicesCards": { + "running services": "Services en cours" + }, + "NoRunningService": { + "launch one": "Clickez ici pour en lancer un", + "no services running": + "Vous n'avez actuellement aucun service en cours d'exécution" + }, "ApiLogsBar": { "ok": "ok" } diff --git a/web/src/ui/i18n/resources/it.tsx b/web/src/ui/i18n/resources/it.tsx index 80e57b60e..5196fcc1d 100644 --- a/web/src/ui/i18n/resources/it.tsx +++ b/web/src/ui/i18n/resources/it.tsx @@ -481,18 +481,21 @@ Sentiti libero di esplorare e prendere il controllo dei tuoi deployment di Kuber "saved": "Salvati", "show all": "Mostrare tutti" }, - "MyServicesCards": { - "running services": "Servizi in corso", - "no services running": "Attualmente non hai alcun servizio in esecuzione", - "launch one": "Clicca qui per avviarne uno", + "ReadmeAndEnvDialog": { "ok": "ok", - "need to copy": "Hai bisogno di copiare i valori non troncati?", - "everything have been printed to the console": - "Tutto è stato registrato nella console.", - "first copy the password": "Inizia copiando la password...", - "open the service": "Aprire il servizio 🚀", "return": "Ritorno" }, + "CopyOpenButton": { + "first copy the password": "Inizia copiando la password...", + "open the service": "Aprire il servizio 🚀" + }, + "MyServicesCards": { + "running services": "Servizi in corso" + }, + "NoRunningService": { + "launch one": "Clicca qui per avviarne uno", + "no services running": "You don't have any service running" + }, "ApiLogsBar": { "ok": "ok" } diff --git a/web/src/ui/i18n/resources/nl.tsx b/web/src/ui/i18n/resources/nl.tsx index 35238a209..13549069e 100644 --- a/web/src/ui/i18n/resources/nl.tsx +++ b/web/src/ui/i18n/resources/nl.tsx @@ -482,17 +482,21 @@ Voel je vrij om te verkennen en de controle te nemen over je Kubernetes-implemen "saved": "Opgeslagen", "show all": "Alles weergeven" }, - "MyServicesCards": { - "running services": "Diensten in uitvoering", - "no services running": "U heeft momenteel geen dienst in uitvoering", - "launch one": "Klik hier om er een te starten", + "ReadmeAndEnvDialog": { "ok": "ok", - "need to copy": "Wilt u de niet-afgeknotte waarden kopiëren ?", - "everything have been printed to the console": "Alles is gelogd in de terminal", - "first copy the password": "Begin met het kopiëren van het wachtwoord...", - "open the service": "De dienst openen 🚀", "return": "Terug" }, + "CopyOpenButton": { + "first copy the password": "Begin met het kopiëren van het wachtwoord...", + "open the service": "De dienst openen 🚀" + }, + "MyServicesCards": { + "running services": "Diensten in uitvoering" + }, + "NoRunningService": { + "launch one": "Klik hier om er een te starten", + "no services running": "You don't have any service running" + }, "ApiLogsBar": { "ok": "ok" } diff --git a/web/src/ui/i18n/resources/no.tsx b/web/src/ui/i18n/resources/no.tsx index c34c05dc3..ccb25a3f5 100644 --- a/web/src/ui/i18n/resources/no.tsx +++ b/web/src/ui/i18n/resources/no.tsx @@ -479,18 +479,21 @@ Føl deg fri til å utforske og ta kontroll over dine Kubernetes-implementeringe "saved": "Lagret", "show all": "Vis alle" }, - "MyServicesCards": { - "running services": "Kjørende tjenester", - "no services running": "Du har ingen kjørende tjenester", - "launch one": "Klikk her for å starte en", + "ReadmeAndEnvDialog": { "ok": "ok", - "need to copy": "Trenger du å kopiere ukuttet verdi?", - "everything have been printed to the console": - "Alt er blitt skrevet ut i konsollen", - "first copy the password": "Først, kopier tjeneste...", - "open the service": "Åpne tjenesten 🚀", "return": "Gå tilbake" }, + "CopyOpenButton": { + "first copy the password": "Først, kopier tjeneste...", + "open the service": "Åpne tjenesten 🚀" + }, + "MyServicesCards": { + "running services": "Kjørende tjenester" + }, + "NoRunningService": { + "launch one": "Klikk her for å starte en", + "no services running": "Du har ingen kjørende tjenester" + }, "ApiLogsBar": { "ok": "ok" } diff --git a/web/src/ui/i18n/resources/zh-CN.tsx b/web/src/ui/i18n/resources/zh-CN.tsx index cb323659c..e75b1e29e 100644 --- a/web/src/ui/i18n/resources/zh-CN.tsx +++ b/web/src/ui/i18n/resources/zh-CN.tsx @@ -415,17 +415,21 @@ export const translations: Translations<"zh-CN"> = { "saved": "已经保存", "show all": "显示所有" }, - "MyServicesCards": { - "running services": "正在运行的服务", - "no services running": "您没有正在运行的服务", - "launch one": "点击来启动此服务", + "ReadmeAndEnvDialog": { "ok": "是", - "need to copy": "需要复制未截断的值?", - "everything have been printed to the console": "所有的信息都会记录在日志里", - "first copy the password": "请复制您的密码", - "open the service": "打开服务 🚀", "return": "返回" }, + "CopyOpenButton": { + "first copy the password": "请复制您的密码", + "open the service": "打开服务 🚀" + }, + "MyServicesCards": { + "running services": "正在运行的服务" + }, + "NoRunningService": { + "launch one": "点击来启动此服务", + "no services running": "You don't have any service running" + }, "ApiLogsBar": { "ok": "是" } diff --git a/web/src/ui/i18n/types.ts b/web/src/ui/i18n/types.ts index 741d63633..2c3e2703c 100644 --- a/web/src/ui/i18n/types.ts +++ b/web/src/ui/i18n/types.ts @@ -51,12 +51,15 @@ export type ComponentKey = | typeof import("ui/pages/catalog/CatalogLauncher/CatalogLauncherConfigurationCard").i18n | typeof import("ui/pages/myServices/MyServices").i18n | typeof import("ui/pages/myServices/MyServicesButtonBar").i18n - | typeof import("ui/pages/myServices/MyServicesCards/MyServicesCard").i18n + | typeof import("ui/pages/myServices/MyServicesCards/MyServicesCard/MyServicesCard").i18n | typeof import("ui/pages/myServices/MyServicesCards/MyServicesCard/MyServicesRunningTime").i18n + | typeof import("ui/pages/myServices/MyServicesCards/MyServicesCard/ReadmeAndEnvDialog/ReadmeAndEnvDialog").i18n + | typeof import("ui/pages/myServices/MyServicesCards/MyServicesCard/ReadmeAndEnvDialog/CopyOpenButton").i18n | typeof import("ui/pages/myServices/MyServicesSavedConfigs/MyServicesSavedConfig/MyServicesSavedConfigOptions").i18n | typeof import("ui/pages/myServices/MyServicesSavedConfigs/MyServicesSavedConfig").i18n | typeof import("ui/pages/myServices/MyServicesSavedConfigs").i18n | typeof import("ui/pages/myServices/MyServicesCards").i18n + | typeof import("ui/pages/myServices/MyServicesCards/NoRunningService").i18n | typeof import("ui/shared/ApiLogsBar").i18n; export type Translations = GenericTranslations< diff --git a/web/src/ui/pages/myServices/MyServices.tsx b/web/src/ui/pages/myServices/MyServices.tsx index f370d6eb7..2ca4f403c 100644 --- a/web/src/ui/pages/myServices/MyServices.tsx +++ b/web/src/ui/pages/myServices/MyServices.tsx @@ -1,6 +1,5 @@ import { useEffect, useMemo, useState, useReducer } from "react"; import { tss, PageHeader } from "ui/theme"; - import { useTranslation } from "ui/i18n"; import { MyServicesButtonBar } from "./MyServicesButtonBar"; import { MyServicesCards } from "./MyServicesCards"; @@ -161,10 +160,9 @@ export default function MyServices(props: Props) { startedAt, monitoringUrl, isStarting, - postInstallInstructions, vaultTokenExpirationTime, s3TokenExpirationTime, - env, + hasPostInstallInstructions, ...rest }) => ({ "serviceId": id, @@ -174,8 +172,7 @@ export default function MyServices(props: Props) { "openUrl": urls[0], monitoringUrl, "startTime": isStarting ? undefined : startedAt, - postInstallInstructions, - env, + hasPostInstallInstructions, "isShared": rest.isShared, "isOwned": rest.isOwned, "ownerUsername": rest.isOwned ? undefined : rest.ownerUsername, @@ -281,6 +278,10 @@ export default function MyServices(props: Props) { catalogExplorerLink={catalogExplorerLink} evtAction={evtMyServiceCardsAction} getServicePassword={getServicePassword} + getEnv={serviceManager.getEnv} + getPostInstallInstructions={ + serviceManager.getPostInstallInstructions + } /> )} void; +}; + +export const CopyOpenButton = memo((props: Props) => { + const { openUrl, servicePassword, onDialogClose, className } = props; + + const [isReadyToOpen, setReadyToOpen] = useReducer( + () => true, + servicePassword === undefined ? true : false + ); + + const copyPasswordToClipBoard = useConstCallback(() => { + assert(servicePassword !== undefined); + navigator.clipboard.writeText(servicePassword); + setReadyToOpen(); + }); + + const { ref1, ref2, largerButtonWidth } = (function useClosure() { + const { + ref: ref1, + domRect: { width: width1 } + } = useDomRect(); + const { + ref: ref2, + domRect: { width: width2 } + } = useDomRect(); + + const refWidth = useRef(0); + + const currWidth = width1 === 0 || width2 === 0 ? 0 : Math.max(width1, width2); + + if (currWidth > refWidth.current) { + refWidth.current = currWidth; + } + + return { + ref1, + ref2, + "largerButtonWidth": refWidth.current + }; + })(); + + const { classes, cx } = useStyles({ largerButtonWidth }); + + const buttonProps = useMemo( + () => + ({ + "variant": "primary", + "href": isReadyToOpen ? openUrl : undefined, + "doOpenNewTabIfHref": true, + "onClick": isReadyToOpen ? onDialogClose : copyPasswordToClipBoard + } as const), + [isReadyToOpen] + ); + + const { t } = useTranslation({ CopyOpenButton }); + + return ( +
+ + +
+ ); +}); + +const useStyles = tss + .withParams<{ largerButtonWidth: number }>() + .withName({ CopyOpenButton }) + .create(({ largerButtonWidth }) => ({ + "root": { + "position": "relative", + "opacity": largerButtonWidth === 0 ? 0 : 1, + "transition": `opacity ease-in-out 250ms` + }, + "button": { + "minWidth": largerButtonWidth + }, + "collapsed": { + "position": "fixed", + "top": 3000 + } + })); + +export const { i18n } = declareComponentKeys< + "first copy the password" | "open the service" +>()({ CopyOpenButton }); diff --git a/web/src/ui/pages/myServices/MyServicesCards/MyServicesCard/MyServicesCard.tsx b/web/src/ui/pages/myServices/MyServicesCards/MyServicesCard/MyServicesCard.tsx index 2930c341f..867b2426d 100644 --- a/web/src/ui/pages/myServices/MyServicesCards/MyServicesCard/MyServicesCard.tsx +++ b/web/src/ui/pages/myServices/MyServicesCards/MyServicesCard/MyServicesCard.tsx @@ -13,6 +13,10 @@ import { objectKeys } from "tsafe/objectKeys"; import { fromNow } from "ui/useMoment"; import { evtLang } from "ui/i18n"; import { declareComponentKeys } from "i18nifty"; +import { ReadmeAndEnvDialog } from "./ReadmeAndEnvDialog"; +import { Evt, NonPostableEvt } from "evt"; +import { useConst } from "powerhooks/useConst"; +import { useEvt } from "evt/hooks"; const runningTimeThreshold = 7 * 24 * 3600 * 1000; @@ -24,12 +28,14 @@ function getDoesHaveBeenRunningForTooLong(params: { startTime: number }): boolea export type Props = { className?: string; + evtAction: NonPostableEvt<"SHOW POST INSTALL INSTRUCTIONS">; packageIconUrl?: string; friendlyName: string; packageName: string; onRequestDelete: (() => void) | undefined; - onRequestShowPostInstallInstructions: (() => void) | undefined; - onRequestShowEnv: () => void; + getPoseInstallInstructions: (() => string) | undefined; + getEnv: () => Record; + getServicePassword: () => Promise; openUrl: string | undefined; monitoringUrl: string | undefined; /** undefined when the service is not yey launched */ @@ -45,12 +51,14 @@ export type Props = { export const MyServicesCard = memo((props: Props) => { const { className, + evtAction, packageIconUrl, friendlyName, packageName, onRequestDelete, - onRequestShowPostInstallInstructions, - onRequestShowEnv, + getEnv, + getPoseInstallInstructions, + getServicePassword, monitoringUrl, openUrl, startTime, @@ -184,85 +192,127 @@ export const MyServicesCard = memo((props: Props) => { [tokensStatus, t] ); + const evtReadmeAndEnvDialogAction = useConst(() => + Evt.create<"SHOW ENV" | "SHOW POST INSTALL INSTRUCTIONS">() + ); + + useEvt( + ctx => { + evtAction.attach( + action => action === "SHOW POST INSTALL INSTRUCTIONS", + ctx, + async () => { + if (getPoseInstallInstructions === undefined) { + return; + } + evtReadmeAndEnvDialogAction.post("SHOW POST INSTALL INSTRUCTIONS"); + } + ); + }, + [evtAction] + ); + return ( -
-
- - - {capitalize(friendlyName)} - -
- {isShared && ( - - + <> +
+
+ + + {capitalize(friendlyName)} + +
+ {isShared && ( + + + + )} + + - )} - - - -
-
-
-
- - {t("service")} - -
- {capitalize(packageName)} - {isShared && ( - +
+
+
+ + {t("service")} + +
+ {capitalize(packageName)} + {isShared && ( + + )} +
+
+
+ + {t("running since")} + + {startTime === undefined ? ( + + ) : ( + )}
-
- - {t("running since")} - +
+ evtReadmeAndEnvDialogAction.post("SHOW ENV")} + /> + {onRequestDelete !== undefined && ( + + )} + {monitoringUrl !== undefined && ( + + )} + {getPoseInstallInstructions !== undefined && ( + + )} +
{startTime === undefined ? ( - + ) : ( - + openUrl && ( + + ) )}
-
- - {onRequestDelete !== undefined && ( - - )} - {monitoringUrl !== undefined && ( - - )} - {onRequestShowPostInstallInstructions !== undefined && ( - - )} -
- {startTime === undefined ? ( - - ) : ( - openUrl && ( - - ) - )} -
-
+ + ); }); diff --git a/web/src/ui/pages/myServices/MyServicesCards/MyServicesCard/ReadmeAndEnvDialog/CopyOpenButton.tsx b/web/src/ui/pages/myServices/MyServicesCards/MyServicesCard/ReadmeAndEnvDialog/CopyOpenButton.tsx new file mode 100644 index 000000000..0c1575221 --- /dev/null +++ b/web/src/ui/pages/myServices/MyServicesCards/MyServicesCard/ReadmeAndEnvDialog/CopyOpenButton.tsx @@ -0,0 +1,114 @@ +import { useMemo, memo, useReducer, useRef } from "react"; +import { tss } from "ui/theme"; +import { Button } from "ui/theme"; +import { useTranslation } from "ui/i18n"; +import { declareComponentKeys } from "i18nifty"; +import { assert } from "tsafe/assert"; +import { useDomRect } from "powerhooks/useDomRect"; +import { useConstCallback } from "powerhooks/useConstCallback"; + +type Props = { + className?: string; + openUrl: string; + servicePassword: string | undefined; + onDialogClose: () => void; +}; + +export const CopyOpenButton = memo((props: Props) => { + const { openUrl, servicePassword, onDialogClose, className } = props; + + const [isReadyToOpen, setReadyToOpen] = useReducer( + () => true, + servicePassword === undefined ? true : false + ); + + const copyPasswordToClipBoard = useConstCallback(() => { + assert(servicePassword !== undefined); + navigator.clipboard.writeText(servicePassword); + setReadyToOpen(); + }); + + const { ref1, ref2, largerButtonWidth } = (function useClosure() { + const { + ref: ref1, + domRect: { width: width1 } + } = useDomRect(); + const { + ref: ref2, + domRect: { width: width2 } + } = useDomRect(); + + const refWidth = useRef(0); + + const currWidth = width1 === 0 || width2 === 0 ? 0 : Math.max(width1, width2); + + if (currWidth > refWidth.current) { + refWidth.current = currWidth; + } + + return { + ref1, + ref2, + "largerButtonWidth": refWidth.current + }; + })(); + + const { classes, cx } = useStyles({ largerButtonWidth }); + + const buttonProps = useMemo( + () => + ({ + "variant": "primary", + "href": isReadyToOpen ? openUrl : undefined, + "doOpenNewTabIfHref": true, + "onClick": isReadyToOpen ? onDialogClose : copyPasswordToClipBoard + } as const), + [isReadyToOpen] + ); + + const { t } = useTranslation({ CopyOpenButton }); + + return ( +
+ + +
+ ); +}); + +const useStyles = tss + .withParams<{ largerButtonWidth: number }>() + .withName({ CopyOpenButton }) + .create(({ largerButtonWidth }) => ({ + "root": { + "position": "relative", + "opacity": largerButtonWidth === 0 ? 0 : 1, + "transition": `opacity ease-in-out 250ms` + }, + "button": { + "minWidth": largerButtonWidth + }, + "collapsed": { + "position": "fixed", + "top": 3000 + } + })); + +export const { i18n } = declareComponentKeys< + "first copy the password" | "open the service" +>()({ CopyOpenButton }); diff --git a/web/src/ui/pages/myServices/MyServicesCards/MyServicesCard/ReadmeAndEnvDialog/ReadmeAndEnvDialog.tsx b/web/src/ui/pages/myServices/MyServicesCards/MyServicesCard/ReadmeAndEnvDialog/ReadmeAndEnvDialog.tsx new file mode 100644 index 000000000..8689c92ef --- /dev/null +++ b/web/src/ui/pages/myServices/MyServicesCards/MyServicesCard/ReadmeAndEnvDialog/ReadmeAndEnvDialog.tsx @@ -0,0 +1,173 @@ +import { useMemo, useState } from "react"; +import { tss } from "ui/theme"; +import { Button } from "ui/theme"; +import { useTranslation } from "ui/i18n"; +import { CircularProgress } from "onyxia-ui/CircularProgress"; +import { declareComponentKeys } from "i18nifty"; +import { smartTrim } from "ui/tools/smartTrim"; +import { Dialog } from "onyxia-ui/Dialog"; +import { Markdown } from "onyxia-ui/Markdown"; +import { useConstCallback } from "powerhooks/useConstCallback"; +import type { NonPostableEvt } from "evt"; +import { useEvt } from "evt/hooks"; +import { CopyOpenButton } from "./CopyOpenButton"; +import { assert } from "tsafe/assert"; + +type Props = { + evtAction: NonPostableEvt<"SHOW ENV" | "SHOW POST INSTALL INSTRUCTIONS">; + startTime: number | undefined; + openUrl: string | undefined; + getServicePassword: () => Promise; + getPostInstallInstructions: (() => string) | undefined; + getEnv: () => Record; +}; + +export function ReadmeAndEnvDialog(props: Props) { + const { + evtAction, + startTime, + openUrl, + getServicePassword, + getPostInstallInstructions, + getEnv + } = props; + + const [dialogDesc, setDialogDesc] = useState< + | { dialogShowingWhat: "env"; env: Record } + | { + dialogShowingWhat: "postInstallInstructions"; + servicePassword: string; + postInstallInstructions: string; + } + | undefined + >(undefined); + + useEvt( + ctx => { + evtAction.attach( + action => action === "SHOW ENV", + ctx, + () => + setDialogDesc({ + "dialogShowingWhat": "env", + "env": getEnv() + }) + ); + + evtAction.attach( + action => action === "SHOW POST INSTALL INSTRUCTIONS", + ctx, + async () => { + assert(getPostInstallInstructions !== undefined); + setDialogDesc({ + "dialogShowingWhat": "postInstallInstructions", + "servicePassword": await getServicePassword(), + "postInstallInstructions": getPostInstallInstructions() + }); + } + ); + }, + [evtAction] + ); + + const onDialogClose = useConstCallback(() => setDialogDesc(undefined)); + + const { classes } = useStyles(); + + const { t } = useTranslation({ ReadmeAndEnvDialog }); + + const { dialogBody, dialogButtons } = useMemo(() => { + if (dialogDesc === undefined) { + return {}; + } + const dialogBody = (() => { + switch (dialogDesc.dialogShowingWhat) { + case "postInstallInstructions": + return dialogDesc.postInstallInstructions; + case "env": + return Object.entries(dialogDesc.env) + .filter(([, value]) => value !== "") + .sort(([a], [b]) => a.localeCompare(b)) + .map( + ([key, value]) => + `**${key}**: \`${smartTrim({ + "text": value, + "minCharAtTheEnd": 4, + "maxLength": 40 + })}\` ` + ) + .join("\n"); + } + })(); + + const dialogButtons = (() => { + switch (dialogDesc.dialogShowingWhat) { + case "postInstallInstructions": + return ( + <> + + + {startTime === undefined ? ( + + ) : ( + openUrl !== undefined && ( + = 0 + ? dialogDesc.servicePassword + : undefined + } + onDialogClose={onDialogClose} + /> + ) + )} + + ); + case "env": + return ( + + ); + } + })(); + + return { dialogBody, dialogButtons }; + }, [dialogDesc, startTime, openUrl, t]); + + return ( + + {dialogBody} +
+ ) + } + isOpen={dialogDesc !== undefined} + onClose={onDialogClose} + buttons={dialogButtons ?? null} + /> + ); +} + +const useStyles = tss.withName({ ReadmeAndEnvDialog }).create(({ theme }) => ({ + "dialogBody": { + "maxHeight": 450, + "overflow": "auto" + }, + "circularProgress": { + ...theme.spacing.rightLeft("margin", 3) + } +})); + +export const { i18n } = declareComponentKeys<"ok" | "return">()({ ReadmeAndEnvDialog }); diff --git a/web/src/ui/pages/myServices/MyServicesCards/MyServicesCard/ReadmeAndEnvDialog/index.ts b/web/src/ui/pages/myServices/MyServicesCards/MyServicesCard/ReadmeAndEnvDialog/index.ts new file mode 100644 index 000000000..588774021 --- /dev/null +++ b/web/src/ui/pages/myServices/MyServicesCards/MyServicesCard/ReadmeAndEnvDialog/index.ts @@ -0,0 +1 @@ +export * from "./ReadmeAndEnvDialog"; diff --git a/web/src/ui/pages/myServices/MyServicesCards/MyServicesCards.tsx b/web/src/ui/pages/myServices/MyServicesCards/MyServicesCards.tsx index cf113f470..4a0bbb3da 100644 --- a/web/src/ui/pages/myServices/MyServicesCards/MyServicesCards.tsx +++ b/web/src/ui/pages/myServices/MyServicesCards/MyServicesCards.tsx @@ -1,23 +1,16 @@ -import { useState, useMemo, useReducer, useRef, memo } from "react"; +import { memo } from "react"; import { MyServicesCard } from "./MyServicesCard"; import { tss, Text } from "ui/theme"; -import { smartTrim } from "ui/tools/smartTrim"; import { useTranslation } from "ui/i18n"; -import { useCallbackFactory } from "powerhooks/useCallbackFactory"; -import { ReactComponent as ServiceNotFoundSvg } from "ui/assets/svg/ServiceNotFound.svg"; -import MuiLink from "@mui/material/Link"; import type { Link } from "type-route"; -import { Dialog } from "onyxia-ui/Dialog"; -import { assert } from "tsafe/assert"; -import { useConstCallback } from "powerhooks/useConstCallback"; -import { Button } from "ui/theme"; -import { Markdown } from "onyxia-ui/Markdown"; -import { symToStr } from "tsafe/symToStr"; import { NonPostableEvt } from "evt"; import { useEvt } from "evt/hooks"; -import { useDomRect } from "powerhooks/useDomRect"; import { CircularProgress } from "onyxia-ui/CircularProgress"; import { declareComponentKeys } from "i18nifty"; +import { useConst } from "powerhooks/useConst"; +import memoize from "memoizee"; +import { Evt } from "evt"; +import { NoRunningService } from "./NoRunningService"; export type Props = { className?: string; @@ -28,21 +21,22 @@ export type Props = { packageIconUrl?: string; friendlyName: string; packageName: string; - env: Record; openUrl: string | undefined; monitoringUrl: string | undefined; startTime: number | undefined; - postInstallInstructions: string | undefined; isShared: boolean; isOwned: boolean; /** undefined when isOwned === true*/ ownerUsername: string | undefined; vaultTokenExpirationTime: number | undefined; s3TokenExpirationTime: number | undefined; + hasPostInstallInstructions: boolean; }[] | undefined; catalogExplorerLink: Link; onRequestDelete(params: { serviceId: string }): void; + getEnv: (params: { serviceId: string }) => Record; + getPostInstallInstructions: (params: { serviceId: string }) => string; evtAction: NonPostableEvt<{ action: "TRIGGER SHOW POST INSTALL INSTRUCTIONS"; serviceId: string; @@ -53,12 +47,14 @@ export type Props = { export const MyServicesCards = memo((props: Props) => { const { className, - onRequestDelete, cards, catalogExplorerLink, - evtAction, isUpdating, - getServicePassword + onRequestDelete, + getEnv, + getServicePassword, + getPostInstallInstructions, + evtAction } = props; const { classes, cx } = useStyles({ @@ -67,136 +63,34 @@ export const MyServicesCards = memo((props: Props) => { const { t } = useTranslation({ MyServicesCards }); - const onRequestDeleteFactory = useCallbackFactory(([serviceId]: [string]) => - onRequestDelete({ serviceId }) - ); - - const [dialogDesc, setDialogDesc] = useState< - | { dialogShowingWhat: "env"; serviceId: string } - | { - dialogShowingWhat: "postInstallInstructions"; - serviceId: string; - servicePassword: string; - } - | undefined - >(undefined); - - const onDialogClose = useConstCallback(() => setDialogDesc(undefined)); - - const onRequestShowEnvOrPostInstallInstructionFactory = useCallbackFactory( - async ([showWhat, serviceId]: ["env" | "postInstallInstructions", string]) => { - switch (showWhat) { - case "env": - setDialogDesc({ "dialogShowingWhat": showWhat, serviceId }); - break; - case "postInstallInstructions": - setDialogDesc({ - "dialogShowingWhat": showWhat, - serviceId, - "servicePassword": await getServicePassword() - }); - break; - } - } + const getEvtMyServicesCardAction = useConst(() => + memoize((_serviceId: string) => Evt.create<"SHOW POST INSTALL INSTRUCTIONS">()) ); useEvt( - ctx => - evtAction.pipe(ctx).$attach( - event => - event.action === "TRIGGER SHOW POST INSTALL INSTRUCTIONS" - ? [event] - : null, - ({ serviceId }) => - onRequestShowEnvOrPostInstallInstructionFactory( - "postInstallInstructions", - serviceId - )() - ), + ctx => { + evtAction.$attach( + action => + action.action !== "TRIGGER SHOW POST INSTALL INSTRUCTIONS" + ? null + : [action.serviceId], + ctx, + serviceId => + getEvtMyServicesCardAction(serviceId).post( + "SHOW POST INSTALL INSTRUCTIONS" + ) + ); + }, [evtAction] ); - const { dialogBody, dialogButton: dialogButtons } = useMemo(() => { - if (dialogDesc === undefined) { - return {}; - } - - const { postInstallInstructions, env, openUrl, startTime } = (cards ?? []).find( - card => card.serviceId === dialogDesc.serviceId - )!; - - const dialogBody = (() => { - switch (dialogDesc.dialogShowingWhat) { - case "postInstallInstructions": - return postInstallInstructions ?? "Your service is ready"; - case "env": - console.log(JSON.stringify(env, null, 2)); - return [ - Object.entries(env) - .filter(([, value]) => value !== "") - .sort(([a], [b]) => a.localeCompare(b)) - .map( - ([key, value]) => - `**${key}**: \`${smartTrim({ - "text": value, - "minCharAtTheEnd": 4, - "maxLength": 40 - })}\` ` - ) - .join("\n"), - " \n", - `**${t("need to copy")}**`, - t("everything have been printed to the console"), - "*Windows/Linux*: `Shift + CTRL + J`", - "*Mac*: `⌥ + ⌘ + J`" - ].join(" \n"); - } - })(); - - const dialogButton = (() => { - switch (dialogDesc.dialogShowingWhat) { - case "postInstallInstructions": - return ( - <> - - - {startTime === undefined ? ( - - ) : ( - openUrl && ( - = 0 - ? dialogDesc.servicePassword - : undefined - } - onDialogClose={onDialogClose} - /> - ) - )} - - ); - case "env": - return ( - - ); - } - })(); - - return { dialogBody, dialogButton }; - }, [dialogDesc, t, cards]); + const getMyServicesFunctionProps = useConst(() => + memoize((serviceId: string) => ({ + "getPoseInstallInstructions": () => getPostInstallInstructions({ serviceId }), + "onRequestDelete": () => onRequestDelete({ serviceId }), + "getEnv": () => getEnv({ serviceId }) + })) + ); return (
@@ -220,58 +114,33 @@ export const MyServicesCards = memo((props: Props) => { ); } - return cards.map(card => ( - - )); + return cards.map(card => { + const { getEnv, getPoseInstallInstructions, onRequestDelete } = + getMyServicesFunctionProps(card.serviceId); + + return ( + + ); + }); })()}
- - {dialogBody} -
- ) - } - isOpen={dialogDesc !== undefined} - onClose={onDialogClose} - buttons={dialogButtons ?? null} - />
); }); -export const { i18n } = declareComponentKeys< - | "running services" - | "no services running" - | "launch one" - | "ok" - | "need to copy" - | "everything have been printed to the console" - | "first copy the password" - | "open the service" - | "return" ->()({ MyServicesCards }); +export const { i18n } = declareComponentKeys<"running services">()({ MyServicesCards }); const useStyles = tss .withParams<{ isThereServicesRunning: boolean }>() @@ -301,178 +170,5 @@ const useStyles = tss }, "noRunningServices": { "height": "100%" - }, - "dialogBody": { - "maxHeight": 450, - "overflow": "auto" - }, - "circularProgress": { - ...theme.spacing.rightLeft("margin", 3) } })); - -const { NoRunningService } = (() => { - type Props = { - className: string; - catalogExplorerLink: Link; - }; - - const NoRunningService = memo((props: Props) => { - const { className, catalogExplorerLink } = props; - - const { classes, cx } = useStyles(); - - const { t } = useTranslation({ MyServicesCards }); - - return ( -
-
- - - {t("no services running")} - - - {t("launch one")} - -
-
- ); - }); - - const useStyles = tss - .withName(`${symToStr({ MyServicesCards })}${symToStr({ NoRunningService })}`) - .create(({ theme }) => ({ - "root": { - "display": "flex", - "alignItems": "center", - "justifyContent": "center" - }, - "innerDiv": { - "textAlign": "center", - "maxWidth": 500 - }, - "svg": { - "fill": theme.colors.palette.dark.greyVariant2, - "width": 100, - "margin": 0 - }, - "h2": { - ...theme.spacing.topBottom("margin", 5) - }, - "link": { - "cursor": "pointer" - } - })); - - return { NoRunningService }; -})(); - -const { CopyOpenButton } = (() => { - type Props = { - className?: string; - openUrl: string; - servicePassword: string | undefined; - onDialogClose: () => void; - }; - - const CopyOpenButton = memo((props: Props) => { - const { openUrl, servicePassword, onDialogClose, className } = props; - - const [isReadyToOpen, setReadyToOpen] = useReducer( - () => true, - servicePassword === undefined ? true : false - ); - - const copyPasswordToClipBoard = useConstCallback(() => { - assert(servicePassword !== undefined); - navigator.clipboard.writeText(servicePassword); - setReadyToOpen(); - }); - - const { ref1, ref2, largerButtonWidth } = (function useClosure() { - const { - ref: ref1, - domRect: { width: width1 } - } = useDomRect(); - const { - ref: ref2, - domRect: { width: width2 } - } = useDomRect(); - - const refWidth = useRef(0); - - const currWidth = width1 === 0 || width2 === 0 ? 0 : Math.max(width1, width2); - - if (currWidth > refWidth.current) { - refWidth.current = currWidth; - } - - return { - ref1, - ref2, - "largerButtonWidth": refWidth.current - }; - })(); - - const { classes, cx } = useStyles({ largerButtonWidth }); - - const buttonProps = useMemo( - () => - ({ - "variant": "primary", - "href": isReadyToOpen ? openUrl : undefined, - "doOpenNewTabIfHref": true, - "onClick": isReadyToOpen ? onDialogClose : copyPasswordToClipBoard - } as const), - [isReadyToOpen] - ); - - const { t } = useTranslation({ MyServicesCards }); - - return ( -
- - -
- ); - }); - - const useStyles = tss - .withParams<{ largerButtonWidth: number }>() - .withName({ CopyOpenButton }) - .create(({ largerButtonWidth }) => ({ - "root": { - "position": "relative", - "opacity": largerButtonWidth === 0 ? 0 : 1, - "transition": `opacity ease-in-out 250ms` - }, - "button": { - "minWidth": largerButtonWidth - }, - "collapsed": { - "position": "fixed", - "top": 3000 - } - })); - - return { CopyOpenButton }; -})(); diff --git a/web/src/ui/pages/myServices/MyServicesCards/NoRunningService.tsx b/web/src/ui/pages/myServices/MyServicesCards/NoRunningService.tsx new file mode 100644 index 000000000..5e07f8fbc --- /dev/null +++ b/web/src/ui/pages/myServices/MyServicesCards/NoRunningService.tsx @@ -0,0 +1,65 @@ +import { memo } from "react"; +import { tss, Text } from "ui/theme"; +import { useTranslation } from "ui/i18n"; +import { ReactComponent as ServiceNotFoundSvg } from "ui/assets/svg/ServiceNotFound.svg"; +import MuiLink from "@mui/material/Link"; +import type { Link } from "type-route"; +import { declareComponentKeys } from "i18nifty"; + +type Props = { + className: string; + catalogExplorerLink: Link; +}; + +export const NoRunningService = memo((props: Props) => { + const { className, catalogExplorerLink } = props; + + const { classes, cx } = useStyles(); + + const { t } = useTranslation({ NoRunningService }); + + return ( +
+
+ + + {t("no services running")} + + + {t("launch one")} + +
+
+ ); +}); + +export const { i18n } = declareComponentKeys<"no services running" | "launch one">()({ + NoRunningService +}); + +const useStyles = tss.withName({ NoRunningService }).create(({ theme }) => ({ + "root": { + "display": "flex", + "alignItems": "center", + "justifyContent": "center" + }, + "innerDiv": { + "textAlign": "center", + "maxWidth": 500 + }, + "svg": { + "fill": theme.colors.palette.dark.greyVariant2, + "width": 100, + "margin": 0 + }, + "h2": { + ...theme.spacing.topBottom("margin", 5) + }, + "link": { + "cursor": "pointer" + } +}));