diff --git a/web/src/core/usecases/serviceManager/selectors.ts b/web/src/core/usecases/serviceManager/selectors.ts index 9c05aa6da..9e481df00 100644 --- a/web/src/core/usecases/serviceManager/selectors.ts +++ b/web/src/core/usecases/serviceManager/selectors.ts @@ -3,15 +3,15 @@ import { createSelector } from "@reduxjs/toolkit"; import { name, type RunningService } from "./state"; -const readyState = (rootState: RootState) => { - const state = rootState[name]; +const state = (rootState: RootState) => rootState[name]; +const readyState = createSelector(state, state => { if (state.stateDescription !== "ready") { return undefined; } return state; -}; +}); const runningServices = createSelector( readyState, @@ -52,10 +52,7 @@ const isThereOwnedSharedServices = createSelector( undefined ); -const commandLogsEntries = createSelector( - readyState, - state => state?.commandLogsEntries ?? [] -); +const commandLogsEntries = createSelector(state, state => state.commandLogsEntries); export const selectors = { runningServices, diff --git a/web/src/core/usecases/serviceManager/state.ts b/web/src/core/usecases/serviceManager/state.ts index 3ca87d85c..9c2723e01 100644 --- a/web/src/core/usecases/serviceManager/state.ts +++ b/web/src/core/usecases/serviceManager/state.ts @@ -2,7 +2,6 @@ import { assert } from "tsafe/assert"; import { createSlice } from "@reduxjs/toolkit"; import type { PayloadAction } from "@reduxjs/toolkit"; import { id } from "tsafe/id"; - import { nestObject } from "core/tools/nestObject"; import * as yaml from "yaml"; @@ -11,6 +10,11 @@ export type State = State.NotInitialized | State.Ready; export namespace State { export type Common = { isUpdating: boolean; + commandLogsEntries: { + cmdId: number; + cmd: string; + resp: string | undefined; + }[]; }; export type NotInitialized = Common & { @@ -23,11 +27,6 @@ export namespace State { envByServiceId: Record>; postInstallInstructionsByServiceId: Record; kubernetesNamespace: string; - commandLogsEntries: { - cmdId: number; - cmd: string; - resp: string | undefined; - }[]; }; } @@ -68,7 +67,8 @@ export const { reducer, actions } = createSlice({ "initialState": id( id({ "stateDescription": "not initialized", - "isUpdating": false + "isUpdating": false, + "commandLogsEntries": [] }) ), "reducers": { @@ -100,14 +100,7 @@ export const { reducer, actions } = createSlice({ envByServiceId, postInstallInstructionsByServiceId, kubernetesNamespace, - "commandLogsEntries": (() => { - switch (state.stateDescription) { - case "ready": - return state.commandLogsEntries; - case "not initialized": - return []; - } - })() + "commandLogsEntries": state.commandLogsEntries }); }, "serviceStarted": ( @@ -186,6 +179,45 @@ export const { reducer, actions } = createSlice({ "\n" ) }); + }, + "commandLogsEntryAdded": ( + state, + { + payload + }: { + payload: { + commandLogsEntry: { + cmdId: number; + cmd: string; + resp: string | undefined; + }; + }; + } + ) => { + const { commandLogsEntry } = payload; + + state.commandLogsEntries.push(commandLogsEntry); + }, + "commandLogsRespUpdated": ( + state, + { + payload + }: { + payload: { + cmdId: number; + resp: string; + }; + } + ) => { + const { cmdId, resp } = payload; + + const commandLogsEntry = state.commandLogsEntries.find( + commandLogsEntry => commandLogsEntry.cmdId === cmdId + ); + + assert(commandLogsEntry !== undefined); + + commandLogsEntry.resp = resp; } } }); diff --git a/web/src/core/usecases/serviceManager/thunks/formatHelmCommands.ts b/web/src/core/usecases/serviceManager/thunks/formatHelmCommands.ts new file mode 100644 index 000000000..c128bb344 --- /dev/null +++ b/web/src/core/usecases/serviceManager/thunks/formatHelmCommands.ts @@ -0,0 +1,90 @@ +export function formatHelmLsResp(params: { + lines: { + namespace: string; + name: string; + revision: string; + updatedTime: number; + status: string; + chart: string; + appVersion: string; + }[]; +}): string { + const { lines } = params; + + const formatTime = (unixTimestampMs: number) => { + const date = new Date(unixTimestampMs); + return date.toISOString() + " " + date.getTimezoneOffset() / 60 + " UTC"; + }; + + const header = { + ...(() => { + const key = "NAME"; + + const nameMaxLength = Math.max(...lines.map(({ name }) => name.length)); + + return { + [key]: `${key}${" ".repeat(Math.max(nameMaxLength - key.length, 7))} ` + }; + })(), + ...(() => { + const key = "NAMESPACE"; + + const maxNamespaceLength = Math.max( + ...lines.map(({ namespace }) => namespace.length) + ); + + return { + [key]: `${key}${" ".repeat( + Math.max(maxNamespaceLength - key.length, 7) + )} ` + }; + })(), + ...(() => { + const key = "REVISION"; + + return { + [key]: `${key}${" ".repeat(7)} ` + }; + })(), + ...(() => { + const key = "UPDATED"; + + return { [key]: `${key}${" ".repeat(formatTime(Date.now()).length)} ` }; + })(), + ...(() => { + const key = "STATUS"; + + return { [key]: `${key}${" ".repeat(7)} ` }; + })(), + ...(() => { + const key = "CHART"; + + const chartMaxLength = Math.max(...lines.map(({ chart }) => chart.length)); + + return { + [key]: `${key}${" ".repeat(Math.max(chartMaxLength - key.length, 7))} ` + }; + })(), + ...(() => { + const key = "APP VERSION"; + + return { [key]: `${key}${" ".repeat(7)} ` }; + })() + }; + + return [ + Object.values(header).join(""), + ...lines.map( + ({ name, namespace, revision, updatedTime, chart, status, appVersion }) => + [ + name.padEnd(header.NAME.length), + namespace.padEnd(header.NAMESPACE.length), + revision.padEnd(header.REVISION.length), + formatTime(updatedTime).padEnd(header.UPDATED.length), + status.padEnd(header.STATUS.length), + chart.padEnd(header.CHART.length), + appVersion.padEnd(header["APP VERSION"].length) + ].join("") + ) + ].join("\n"); +} diff --git a/web/src/core/usecases/serviceManager/thunks/index.ts b/web/src/core/usecases/serviceManager/thunks/index.ts new file mode 100644 index 000000000..1b312a314 --- /dev/null +++ b/web/src/core/usecases/serviceManager/thunks/index.ts @@ -0,0 +1 @@ +export * from "./thunks"; diff --git a/web/src/core/usecases/serviceManager/thunks.ts b/web/src/core/usecases/serviceManager/thunks/thunks.ts similarity index 79% rename from web/src/core/usecases/serviceManager/thunks.ts rename to web/src/core/usecases/serviceManager/thunks/thunks.ts index 6630060aa..0f96953c2 100644 --- a/web/src/core/usecases/serviceManager/thunks.ts +++ b/web/src/core/usecases/serviceManager/thunks/thunks.ts @@ -1,13 +1,15 @@ import { id } from "tsafe/id"; -import * as deploymentRegion from "../deploymentRegion"; -import * as projectConfigs from "../projectConfigs"; +import * as deploymentRegion from "core/usecases/deploymentRegion"; +import * as projectConfigs from "core/usecases/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"; +import { name, actions } from "../state"; +import type { RunningService } from "../state"; +import type { OnyxiaApi } from "core/ports/OnyxiaApi"; +import { formatHelmLsResp } from "./formatHelmCommands"; export const thunks = { "setActive": @@ -36,7 +38,7 @@ export const thunks = { "update": () => async (...args) => { - const [dispatch, getState, { onyxiaApi }] = args; + const [dispatch, getState] = args; { const state = getState()[name]; @@ -48,6 +50,8 @@ export const thunks = { dispatch(actions.updateStarted()); + const onyxiaApi = dispatch(privateThunks.getLoggedOnyxiaApi()); + const runningServicesRaw = await onyxiaApi.getRunningServices(); //NOTE: We do not have the catalog id so we search in every catalog. @@ -271,11 +275,73 @@ const privateThunks = { s3TokensTTLms, vaultTokenTTLms }))); + }, + "getLoggedOnyxiaApi": + () => + (...args): OnyxiaApi => { + const [dispatch, getState, extraArg] = args; + + const sliceContext = getContext(extraArg); + + { + const { loggedOnyxiaApi } = sliceContext; + if (loggedOnyxiaApi !== undefined) { + return loggedOnyxiaApi; + } + } + + const { onyxiaApi } = extraArg; + + sliceContext.loggedOnyxiaApi = { + ...onyxiaApi, + "getRunningServices": async () => { + const { namespace: kubernetesNamespace } = + projectConfigs.selectors.selectedProject(getState()); + + const cmdId = Date.now(); + + const commandLogsEntry = { + cmdId, + "cmd": `helm list --namespace ${kubernetesNamespace}`, + "resp": undefined + }; + + dispatch( + actions.commandLogsEntryAdded({ + commandLogsEntry + }) + ); + + const runningServices = await onyxiaApi.getRunningServices(); + + dispatch( + actions.commandLogsRespUpdated({ + cmdId, + "resp": formatHelmLsResp({ + "lines": runningServices.map(runningService => ({ + "name": runningService.packageName, + "namespace": kubernetesNamespace, + "revision": "TODO", + "updatedTime": runningService.startedAt, + "status": "TODO", + "chart": "TODO", + "appVersion": "TODO" + })) + }) + }) + ); + + return runningServices; + } + }; + + return dispatch(privateThunks.getLoggedOnyxiaApi()); } } satisfies Thunks; const { getContext } = createUsecaseContextApi(() => ({ "prDefaultTokenTTL": id< Promise<{ s3TokensTTLms: number; vaultTokenTTLms: number }> | undefined - >(undefined) + >(undefined), + "loggedOnyxiaApi": id(undefined) })); diff --git a/web/src/ui/pages/myServices/MyServices.tsx b/web/src/ui/pages/myServices/MyServices.tsx index c43d1549c..da7f25176 100644 --- a/web/src/ui/pages/myServices/MyServices.tsx +++ b/web/src/ui/pages/myServices/MyServices.tsx @@ -284,7 +284,10 @@ export default function MyServices(props: Props) { {isCommandBarEnabled && ( @@ -421,10 +424,14 @@ const useStyles = tss "commandBar": { "position": "absolute", "right": 0, - "width": "42%", "top": commandBarTop, "zIndex": 1, "opacity": commandBarTop === 0 ? 0 : 1, - "transition": "opacity 750ms linear" + "transition": "opacity 750ms linear", + "width": "min(100%, 1100px)" + }, + "commandBarWhenExpended": { + "width": "min(100%, 1350px)", + "transition": "width 70ms linear" } }));