diff --git a/web/src/core/core.ts b/web/src/core/core.ts index 6ebf8a009..d18fd0b0e 100644 --- a/web/src/core/core.ts +++ b/web/src/core/core.ts @@ -189,8 +189,6 @@ export async function createCore(params: CoreParams) { ); } - core.dispatch(usecases.runningService.protectedThunks.initialize()); - return core; } diff --git a/web/src/core/usecases/index.ts b/web/src/core/usecases/index.ts index 67b03c5b0..16632bfd5 100644 --- a/web/src/core/usecases/index.ts +++ b/web/src/core/usecases/index.ts @@ -6,7 +6,7 @@ import * as launcher from "./launcher"; import * as projectConfigs from "./projectConfigs"; import * as publicIp from "./publicIp"; import * as restorablePackageConfigs from "./restorablePackageConfigs"; -import * as runningService from "./runningService"; +import * as serviceManager from "./serviceManager"; import * as userAuthentication from "./userAuthentication"; import * as userConfigs from "./userConfigs"; import * as secretsEditor from "./secretsEditor"; @@ -24,7 +24,7 @@ export const usecases = { projectConfigs, publicIp, restorablePackageConfigs, - runningService, + serviceManager, userAuthentication, userConfigs, secretsEditor, diff --git a/web/src/core/usecases/serviceManager/index.ts b/web/src/core/usecases/serviceManager/index.ts new file mode 100644 index 000000000..6e655c5cd --- /dev/null +++ b/web/src/core/usecases/serviceManager/index.ts @@ -0,0 +1,3 @@ +export * from "./state"; +export * from "./thunks"; +export * from "./selectors"; diff --git a/web/src/core/usecases/serviceManager/selectors.ts b/web/src/core/usecases/serviceManager/selectors.ts new file mode 100644 index 000000000..61b6a0ad2 --- /dev/null +++ b/web/src/core/usecases/serviceManager/selectors.ts @@ -0,0 +1,44 @@ +import type { State as RootState } from "core/core"; +import { createSelector } from "@reduxjs/toolkit"; + +import { name, type RunningService } from "./state"; + +const runningServices = (rootState: RootState): RunningService[] | undefined => { + const { runningServices } = rootState[name]; + + 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; +}; + +const deletableRunningServices = createSelector(runningServices, runningServices => + (runningServices ?? []).filter(({ isOwned }) => isOwned) +); + +const isThereNonOwnedServices = createSelector( + runningServices, + runningServices => + (runningServices ?? []).find(({ isOwned }) => !isOwned) !== undefined +); + +const isThereOwnedSharedServices = createSelector( + runningServices, + runningServices => + (runningServices ?? []).find(({ isOwned, isShared }) => isOwned && isShared) !== + undefined +); + +export const selectors = { + runningServices, + deletableRunningServices, + isUpdating, + isThereNonOwnedServices, + isThereOwnedSharedServices +}; diff --git a/web/src/core/usecases/serviceManager/state.ts b/web/src/core/usecases/serviceManager/state.ts new file mode 100644 index 000000000..5447f18e0 --- /dev/null +++ b/web/src/core/usecases/serviceManager/state.ts @@ -0,0 +1,104 @@ +import { assert } from "tsafe/assert"; +import { createSlice } from "@reduxjs/toolkit"; +import type { PayloadAction } from "@reduxjs/toolkit"; +import { id } from "tsafe/id"; + +type State = { + isUpdating: boolean; + runningServices: undefined | RunningService[]; +}; + +export type RunningService = RunningService.Owned | RunningService.NotOwned; + +export declare namespace RunningService { + export type Common = { + id: string; + packageName: string; + friendlyName: string; + logoUrl: string | undefined; + monitoringUrl: string | undefined; + isStarting: boolean; + startedAt: number; + /** Undefined if the service don't use the token */ + vaultTokenExpirationTime: number | undefined; + s3TokenExpirationTime: number | undefined; + urls: string[]; + postInstallInstructions: string | undefined; + env: Record; + }; + + export type Owned = Common & { + isShared: boolean; + isOwned: true; + }; + + export type NotOwned = Common & { + isShared: true; + isOwned: false; + ownerUsername: string; + }; +} + +export const name = "serviceManager"; + +export const { reducer, actions } = createSlice({ + name, + "initialState": id({ + "isUpdating": false, + "runningServices": undefined + }), + "reducers": { + "updateStarted": state => { + state.isUpdating = true; + }, + "updateCompleted": ( + _state, + { payload }: PayloadAction<{ runningServices: RunningService[] }> + ) => { + const { runningServices } = payload; + + return id({ + "isUpdating": false, + runningServices + }); + }, + "serviceStarted": ( + state, + { + payload + }: PayloadAction<{ + serviceId: string; + doOverwriteStaredAtToNow: boolean; + }> + ) => { + const { serviceId, doOverwriteStaredAtToNow } = payload; + const { runningServices } = state; + + assert(runningServices !== undefined); + + const runningService = runningServices.find(({ id }) => id === serviceId); + + if (runningService === undefined) { + return; + } + + runningService.isStarting = false; + + if (doOverwriteStaredAtToNow) { + //NOTE: Harmless hack to improve UI readability. + runningService.startedAt = Date.now(); + } + }, + "serviceStopped": (state, { payload }: PayloadAction<{ serviceId: string }>) => { + const { serviceId } = payload; + + const { runningServices } = state; + assert(runningServices !== undefined); + + runningServices.splice( + runningServices.findIndex(({ id }) => id === serviceId), + 1 + ); + } + } +}); diff --git a/web/src/core/usecases/runningService.ts b/web/src/core/usecases/serviceManager/thunks.ts similarity index 57% rename from web/src/core/usecases/runningService.ts rename to web/src/core/usecases/serviceManager/thunks.ts index 112fa16e9..14e7d4cc8 100644 --- a/web/src/core/usecases/runningService.ts +++ b/web/src/core/usecases/serviceManager/thunks.ts @@ -1,132 +1,36 @@ -import { assert } from "tsafe/assert"; -import { createSlice } from "@reduxjs/toolkit"; -import type { PayloadAction } from "@reduxjs/toolkit"; import { id } from "tsafe/id"; -import * as deploymentRegion from "./deploymentRegion"; -import * as projectConfigs from "./projectConfigs"; -import type { Thunks, State as RootState } from "../core"; +import * as deploymentRegion from "../deploymentRegion"; +import * as projectConfigs from "../projectConfigs"; +import type { Thunks } from "core/core"; import { exclude } from "tsafe/exclude"; import { createUsecaseContextApi } from "redux-clean-architecture"; -import { createSelector } from "@reduxjs/toolkit"; - -type State = { - isUserWatching: boolean; - isUpdating: boolean; - runningServices: undefined | RunningService[]; -}; - -export type RunningService = RunningService.Owned | RunningService.NotOwned; - -export declare namespace RunningService { - export type Common = { - id: string; - packageName: string; - friendlyName: string; - logoUrl: string | undefined; - monitoringUrl: string | undefined; - isStarting: boolean; - startedAt: number; - /** Undefined if the service don't use the token */ - vaultTokenExpirationTime: number | undefined; - s3TokenExpirationTime: number | undefined; - urls: string[]; - postInstallInstructions: string | undefined; - env: Record; - }; - - export type Owned = Common & { - isShared: boolean; - isOwned: true; - }; - - export type NotOwned = Common & { - isShared: true; - isOwned: false; - ownerUsername: string; - }; -} - -export const name = "runningService"; - -export const { reducer, actions } = createSlice({ - name, - "initialState": id({ - "isUserWatching": false, - "isUpdating": false, - "runningServices": undefined - }), - "reducers": { - "isUserWatchingChanged": ( - state, - { payload }: PayloadAction<{ isUserWatching: boolean }> - ) => { - const { isUserWatching } = payload; - - state.isUserWatching = isUserWatching; - }, - "updateStarted": state => { - state.isUpdating = true; - }, - "updateCompleted": ( - state, - { payload }: PayloadAction<{ runningServices: RunningService[] }> - ) => { - const { runningServices } = payload; - - return id({ - "isUpdating": false, - "isUserWatching": state.isUserWatching, - runningServices - }); - }, - "serviceStarted": ( - state, - { - payload - }: PayloadAction<{ - serviceId: string; - doOverwriteStaredAtToNow: boolean; - }> - ) => { - const { serviceId, doOverwriteStaredAtToNow } = payload; - const { runningServices } = state; - - assert(runningServices !== undefined); - - const runningService = runningServices.find(({ id }) => id === serviceId); - - if (runningService === undefined) { - return; - } - - runningService.isStarting = false; - - if (doOverwriteStaredAtToNow) { - //NOTE: Harmless hack to improve UI readability. - runningService.startedAt = Date.now(); - } - }, - "serviceStopped": (state, { payload }: PayloadAction<{ serviceId: string }>) => { - const { serviceId } = payload; - - const { runningServices } = state; - assert(runningServices !== undefined); - - runningServices.splice( - runningServices.findIndex(({ id }) => id === serviceId), - 1 - ); - } - } -}); +import { Evt } from "evt"; +import { name, actions } from "./state"; +import type { RunningService } from "./state"; export const thunks = { - "setIsUserWatching": - (isUserWatching: boolean) => + "setActive": + () => (...args) => { - const [dispatch] = args; + const [dispatch, , { evtAction }] = args; + + const ctx = Evt.newCtx(); + + evtAction + .pipe( + ctx, + action => + action.sliceName === "projectConfigs" && + action.actionName === "projectChanged" + ) + .toStateful() + .attach(() => dispatch(thunks.update())); + + function setInactive() { + ctx.done(); + } - dispatch(actions.isUserWatchingChanged({ isUserWatching })); + return { setInactive }; }, "update": () => @@ -134,7 +38,7 @@ export const thunks = { const [dispatch, getState, { onyxiaApi }] = args; { - const state = getState().runningService; + const state = getState()[name]; if (state.isUpdating) { return; @@ -280,40 +184,6 @@ export const thunks = { } } satisfies Thunks; -export const protectedThunks = { - "initialize": - () => - (...args) => { - const [dispatch, getState, { evtAction }] = args; - - evtAction.attach( - event => - event.sliceName === "runningService" && - event.actionName === "isUserWatchingChanged" && - event.payload.isUserWatching, - () => dispatch(thunks.update()) - ); - - evtAction.attach( - event => - event.sliceName === "projectConfigs" && - event.actionName === "projectChanged" && - getState().runningService.isUserWatching, - async () => { - if (getState().runningService.isUpdating) { - await evtAction.waitFor( - event => - event.sliceName === "runningService" && - event.actionName === "updateCompleted" - ); - } - - dispatch(thunks.update()); - } - ); - } -} satisfies Thunks; - const privateThunks = { /** We ask tokens just to tel how long is their lifespan */ "getDefaultTokenTTL": @@ -358,46 +228,3 @@ const { getContext } = createUsecaseContextApi(() => ({ Promise<{ s3TokensTTLms: number; vaultTokenTTLms: number }> | undefined >(undefined) })); - -export const selectors = (() => { - const runningServices = (rootState: RootState): RunningService[] | undefined => { - const { runningServices } = rootState[name]; - - 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; - }; - - const deletableRunningServices = createSelector(runningServices, runningServices => - (runningServices ?? []).filter(({ isOwned }) => isOwned) - ); - - const isThereNonOwnedServices = createSelector( - runningServices, - runningServices => - (runningServices ?? []).find(({ isOwned }) => !isOwned) !== undefined - ); - - const isThereOwnedSharedServices = createSelector( - runningServices, - runningServices => - (runningServices ?? []).find( - ({ isOwned, isShared }) => isOwned && isShared - ) !== undefined - ); - - return { - runningServices, - deletableRunningServices, - isUpdating, - isThereNonOwnedServices, - isThereOwnedSharedServices - }; -})(); diff --git a/web/src/ui/pages/myServices/MyServices.tsx b/web/src/ui/pages/myServices/MyServices.tsx index ba407d832..f370d6eb7 100644 --- a/web/src/ui/pages/myServices/MyServices.tsx +++ b/web/src/ui/pages/myServices/MyServices.tsx @@ -32,18 +32,18 @@ export default function MyServices(props: Props) { const { t } = useTranslation({ MyServices }); /* prettier-ignore */ - const { runningService, restorablePackageConfig, projectConfigs } = useCoreFunctions(); + const { serviceManager, restorablePackageConfig, projectConfigs } = useCoreFunctions(); /* prettier-ignore */ const { displayableConfigs } = useCoreState(selectors.restorablePackageConfig.displayableConfigs); /* prettier-ignore */ - const { isUpdating } = useCoreState(selectors.runningService.isUpdating); - const { runningServices } = useCoreState(selectors.runningService.runningServices); + const { isUpdating } = useCoreState(selectors.serviceManager.isUpdating); + const { runningServices } = useCoreState(selectors.serviceManager.runningServices); /* prettier-ignore */ - const { deletableRunningServices } = useCoreState(selectors.runningService.deletableRunningServices); + const { deletableRunningServices } = useCoreState(selectors.serviceManager.deletableRunningServices); /* prettier-ignore */ - const { isThereOwnedSharedServices } = useCoreState(selectors.runningService.isThereOwnedSharedServices); + const { isThereOwnedSharedServices } = useCoreState(selectors.serviceManager.isThereOwnedSharedServices); /* prettier-ignore */ - const { isThereNonOwnedServices } = useCoreState(selectors.runningService.isThereNonOwnedServices); + const { isThereNonOwnedServices } = useCoreState(selectors.serviceManager.isThereNonOwnedServices); const [password, setPassword] = useState(undefined); @@ -68,7 +68,7 @@ export default function MyServices(props: Props) { routes.catalogExplorer().push(); return; case "refresh": - runningService.update(); + serviceManager.update(); return; case "password": assert(password !== undefined); @@ -89,8 +89,8 @@ export default function MyServices(props: Props) { }, []); useEffect(() => { - runningService.setIsUserWatching(true); - return () => runningService.setIsUserWatching(false); + const { setInactive } = serviceManager.setActive(); + return () => setInactive(); }, []); const { isSavedConfigsExtended } = route.params; @@ -239,12 +239,12 @@ export default function MyServices(props: Props) { const onDialogCloseFactory = useCallbackFactory(([doDelete]: [boolean]) => { if (doDelete) { if (serviceIdRequestedToBeDeleted) { - runningService.stopService({ + serviceManager.stopService({ "serviceId": serviceIdRequestedToBeDeleted }); } else { deletableRunningServices.map(({ id }) => - runningService.stopService({ "serviceId": id }) + serviceManager.stopService({ "serviceId": id }) ); } }