From cfee472b94c0ccf9c17fc3d4cbaea7dbae71534d Mon Sep 17 00:00:00 2001 From: yolossn Date: Wed, 22 Jul 2026 14:03:45 +0530 Subject: [PATCH 1/5] app: Add AKS Hybrid/Edge proxy lifecycle handlers Adds main-process handlers to start, stop, and force-stop (on app quit) the AKS Hybrid/Edge proxy child processes, tracked per cluster. Also hardens that lifecycle: - Windows: force-kill the child tree with taskkill /T /F (no POSIX process groups; SIGTERM and SIGKILL both map to a forced tree kill). - Keep a stopped proxy tracked until its child actually exits, so a quick Start after Stop can't spawn a second proxy that fights over the port. - Cancel the SIGKILL fallback once the child exits, avoiding a signal to a reused PID. - Only lifecycle-manage the real 'az connectedk8s proxy' invocation, and reserve/settle in-flight starts so back-to-back start/stop IPC can't leak or outlive a proxy. Signed-off-by: yolossn --- app/electron/main.ts | 12 +- app/electron/preload.ts | 2 + app/electron/runCmd.ts | 244 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 257 insertions(+), 1 deletion(-) diff --git a/app/electron/main.ts b/app/electron/main.ts index 03e405ca1..df6b5bd6b 100644 --- a/app/electron/main.ts +++ b/app/electron/main.ts @@ -54,7 +54,13 @@ import { getPluginBinDirectories, PluginManager, } from './plugin-management'; -import { addRunCmdConsent, removeRunCmdConsent, runScript, setupRunCmdHandlers } from './runCmd'; +import { + addRunCmdConsent, + killAllProxies, + removeRunCmdConsent, + runScript, + setupRunCmdHandlers, +} from './runCmd'; import { setupSecureStorageHandlers } from './secure-storage'; import { cleanupHeadlampTray, createHeadlampTray } from './tray'; import windowSize from './windowSize'; @@ -2110,6 +2116,10 @@ async function startElectron() { app.once('before-quit', async () => { isQuitting = true; + // Kill any running AKS Hybrid & Edge proxies (the `az connectedk8s proxy` + // processes and their arcProxy daemons) so they don't orphan to launchd and + // keep running after the app closes. + killAllProxies(); cleanupHeadlampTray(); hasTray = false; saveZoomFactor(cachedZoom); diff --git a/app/electron/preload.ts b/app/electron/preload.ts index b1772aa28..0795356a1 100644 --- a/app/electron/preload.ts +++ b/app/electron/preload.ts @@ -38,6 +38,8 @@ contextBridge.exposeInMainWorld('desktopApi', { 'appConfig', 'pluginsLoaded', 'run-command', + 'start-aks-hybrid-edge-proxy', + 'stop-aks-hybrid-edge-proxy', 'plugin-manager', 'request-backend-token', 'request-plugin-permission-secrets', diff --git a/app/electron/runCmd.ts b/app/electron/runCmd.ts index 82e981f6e..2e485d126 100644 --- a/app/electron/runCmd.ts +++ b/app/electron/runCmd.ts @@ -26,6 +26,137 @@ import i18n from './i18next.config'; import { defaultPluginsDir, defaultUserPluginsDir } from './plugin-management'; import { loadSettings, saveSettings, SETTINGS_PATH } from './settings'; +/** + * Active AKS Hybrid & Edge proxy children (`az connectedk8s proxy`), owned by + * the app layer and keyed by cluster name. The main process is the single + * source of truth for proxy lifecycle: start is idempotent per cluster, stop + * kills by cluster, and all are killed on app quit — so a proxy (and the + * `arcProxy` daemon it spawns on 127.0.0.1:47011) never orphans to launchd and + * keeps serving the cluster after the app closes. + * + * `group` is true when the child was spawned detached (its own process group), + * so it can be group-killed to also stop that grandchild daemon. + */ +const proxiesByCluster = new Map< + string, + { child: ChildProcessWithoutNullStreams; group: boolean } +>(); + +/** + * Clusters whose proxy start is in-flight but not yet tracked in + * `proxiesByCluster` (the child isn't registered until after `spawn`, and the + * start path is async). Reserved synchronously so back-to-back start IPC events + * can't both pass the idempotency guard and launch duplicate proxies. + */ +const startingProxies = new Set(); + +/** + * Clusters for which a Stop arrived while their proxy start was still in-flight + * (present in `startingProxies` but not yet tracked). The proxy is torn down the + * moment it becomes tracked after spawn, so an in-flight start can't outlive a + * Stop request. + */ +const stopRequestedProxies = new Set(); + +/** Whether a proxy is already running for the given cluster. */ +export function hasProxyForCluster(cluster: string): boolean { + return proxiesByCluster.has(cluster); +} + +/** + * Sends `signal` to a tracked proxy. Detached children are signalled as a whole + * process group (negative pid) so the daemon they launched (arcProxy) is + * included; others are signalled directly. Best-effort and synchronous. + */ +function signalChildEntry( + { child, group }: { child: ChildProcessWithoutNullStreams; group: boolean }, + signal: NodeJS.Signals +): void { + const pid = child.pid; + if (!pid) { + return; + } + try { + if (process.platform === 'win32') { + // Windows has no POSIX process groups, so `process.kill(-pid)` fails with + // EINVAL and can't reach the daemon (arcProxy) the CLI spawned. Kill the + // whole child tree with taskkill (/T = tree, /F = force). Windows has no + // reliable graceful console signal, so both SIGTERM and SIGKILL map to a + // forced tree kill here. + const killer = spawn('taskkill', ['/pid', String(pid), '/T', '/F']); + // taskkill exits non-zero if the tree is already gone; ignore spawn errors + // so a best-effort teardown never throws. + killer.on('error', () => {}); + } else if (group) { + process.kill(-pid, signal); + } else { + child.kill(signal); + } + } catch (err) { + // ESRCH = process/group already exited, which is the expected outcome for a + // teardown — ignore it. Anything else (EPERM, EINVAL) means the kill + // genuinely failed and a proxy may be orphaned, so surface it. + if ((err as NodeJS.ErrnoException).code !== 'ESRCH') { + console.error(`[AKS][main] failed to ${signal} proxy pid ${pid}:`, err); + } + } +} + +/** + * Stops the proxy for a specific cluster, if any. + * + * Targets the whole tree — `az connectedk8s proxy` + the `arcProxy` daemon it + * spawns — because arcProxy is what actually serves the cluster on + * 127.0.0.1:47011, so signalling only the CLI leaves the cluster reachable and + * Stop appears to do nothing. + * + * Cross-platform behaviour differs (see {@link signalChildEntry}): + * - **POSIX**: a graceful `SIGTERM` to the process group so arcProxy closes its + * listener and releases the port cleanly, with a `SIGKILL` fallback a few + * seconds later only if something ignored the SIGTERM. (An abrupt SIGKILL + * leaves arcProxy in a bad state and a subsequent Start can't reconnect.) + * - **Windows**: no process-group signalling and no reliable graceful console + * signal, so both calls force-kill the tree with `taskkill /T /F`; the port + * is released on process exit rather than a graceful listener close. + */ +export function stopProxyForCluster(cluster: string): void { + const entry = proxiesByCluster.get(cluster); + if (!entry) { + return; + } + // Intentionally keep the entry tracked until the child actually exits (the + // `untrack()` handler removes it on exit). Removing it here would make + // hasProxyForCluster() false while the process is still shutting down, letting + // a quick Start spawn a second proxy and fight over the port/arcProxy daemon. + + // POSIX: graceful group stop (CLI + arcProxy). Windows: forced taskkill tree + // stop — see signalChildEntry for the platform split. + signalChildEntry(entry, 'SIGTERM'); + + // Force-kill fallback a few seconds later, only if the child hasn't already + // exited — avoids ESRCH noise and, more importantly, signalling a reused PID. + // The timer is cancelled when the child exits (the common case after SIGTERM). + const killTimer = setTimeout(() => { + if (entry.child.exitCode === null && entry.child.signalCode === null) { + signalChildEntry(entry, 'SIGKILL'); + } + }, 4000); + entry.child.once('exit', () => clearTimeout(killTimer)); +} + +/** + * Kills every tracked proxy. Intended to run from the app's `before-quit` + * handler so proxies don't survive the app. + */ +export function killAllProxies(): void { + for (const entry of proxiesByCluster.values()) { + // On quit, force-kill for a guaranteed teardown (no time to wait on a + // graceful shutdown as the app is exiting). + signalChildEntry(entry, 'SIGKILL'); + } + proxiesByCluster.clear(); +} + /** * Data sent from the renderer process when a 'run-command' event is emitted. */ @@ -413,6 +544,33 @@ export async function handleRunCommand( }, }); + // When the caller tags the command with a `cluster` (the AKS Hybrid & Edge + // proxy does, via handleStartProxy), track the child by cluster so the app + // layer owns its lifecycle — stop-by-cluster and kill-on-quit. Such commands + // are spawned `detached: true`, making the child a process-group leader we can + // group-kill to also stop the daemon it launches (arcProxy). + const proxyCluster = (commandData.options as { cluster?: string })?.cluster; + const isDetached = !!(commandData.options as { detached?: boolean })?.detached; + // Only lifecycle-manage the actual `az connectedk8s proxy` invocation. Guarding + // on the command keeps an unrelated run-command that happens to set + // options.cluster from being tracked (and later killed) as if it were a proxy. + const isAksProxyCommand = command === 'az' && args[0] === 'connectedk8s' && args[1] === 'proxy'; + if (proxyCluster && isAksProxyCommand && !proxiesByCluster.has(proxyCluster)) { + proxiesByCluster.set(proxyCluster, { child, group: isDetached }); + // A Stop that arrived while this proxy was still starting can now be honored, + // now that the child exists and is tracked. + if (stopRequestedProxies.delete(proxyCluster)) { + stopProxyForCluster(proxyCluster); + } + } + const untrack = () => { + // Guard: only clear if this exact child is still the tracked one, so a late + // exit from a replaced proxy can't evict a newer one. + if (proxyCluster && proxiesByCluster.get(proxyCluster)?.child === child) { + proxiesByCluster.delete(proxyCluster); + } + }; + child.stdout.on('data', (data: string | Buffer) => { event.sender.send('command-stdout', commandData.id, data.toString()); }); @@ -422,11 +580,13 @@ export async function handleRunCommand( }); child.on('error', (err: Error) => { + untrack(); event.sender.send('command-stderr', commandData.id, err.message); event.sender.send('command-exit', commandData.id, -1); }); child.on('exit', (code: number | null) => { + untrack(); event.sender.send('command-exit', commandData.id, code); }); } @@ -513,6 +673,90 @@ export function setupRunCmdHandlers(mainWindow: BrowserWindow | null, ipcMain: E async (event, eventData) => await handleRunCommand(event, eventData, mainWindow, permissionSecrets) ); + + // Starts the AKS Hybrid & Edge proxy for a cluster. App-layer owned and + // idempotent: if a proxy is already running for the cluster this is a no-op, + // so a duplicate Start never launches a second `az connectedk8s proxy` (which + // would bounce the shared arcProxy daemon). Delegates to the run-command path + // so the existing consent / permission-secret checks and child streaming + // apply; the child is spawned detached and tracked by cluster. + ipcMain.on('start-aks-hybrid-edge-proxy', async (event, eventData) => { + const { cluster, subscriptionId, resourceGroup } = (eventData ?? {}) as { + cluster?: string; + subscriptionId?: string; + resourceGroup?: string; + }; + console.log( + `[AKS][main] start-aks-hybrid-edge-proxy received: cluster=${cluster} ` + + `alreadyRunning=${cluster ? hasProxyForCluster(cluster) : 'n/a'}` + ); + if ( + !cluster || + !subscriptionId || + !resourceGroup || + hasProxyForCluster(cluster) || + startingProxies.has(cluster) + ) { + return; + } + // Reserve synchronously: the child isn't tracked in proxiesByCluster until + // after spawn (inside the async handleRunCommand below), so without this a + // second back-to-back event could pass the guard and launch a duplicate. + startingProxies.add(cluster); + // Drop any stale stop-request from a previous start so it can't tear down + // this fresh one. + stopRequestedProxies.delete(cluster); + const commandData: CommandData = { + id: `aks-hybrid-edge-proxy:${cluster}:${cryptoRandom()}`, + command: 'az', + args: [ + 'connectedk8s', + 'proxy', + '--subscription', + subscriptionId, + '--resource-group', + resourceGroup, + '--name', + cluster, + ], + // `cluster` tags the child for cluster-keyed tracking; `detached` makes it + // a process-group leader so it (and arcProxy) can be group-killed. + options: { detached: true, cluster }, + permissionSecrets, + }; + // Log only the cluster, not the full arg list — it carries the Azure + // subscription id and resource group, which can be sensitive and may end up + // in diagnostic bundles. + console.log(`[AKS][main] spawning proxy for cluster=${cluster}`); + try { + await handleRunCommand(event, commandData, mainWindow, permissionSecrets); + } finally { + // Once spawned, proxiesByCluster owns the idempotency guard; drop the + // in-flight reservation (also clears it if the start failed before spawn). + startingProxies.delete(cluster); + } + }); + + // Stops the AKS Hybrid & Edge proxy for a cluster: signals the tracked child + // (and its arcProxy daemon); it stays tracked until it actually exits. Keyed by + // cluster name, so it works even after a renderer reload (which has no memory + // of the command id). + ipcMain.on('stop-aks-hybrid-edge-proxy', (_event, eventData) => { + const cluster = (eventData as { cluster?: unknown })?.cluster; + console.log( + `[AKS][main] stop-aks-hybrid-edge-proxy received: cluster=${String(cluster)} ` + + `tracked=${typeof cluster === 'string' ? hasProxyForCluster(cluster) : 'n/a'}` + ); + if (typeof cluster === 'string') { + if (hasProxyForCluster(cluster)) { + stopProxyForCluster(cluster); + } else if (startingProxies.has(cluster)) { + // Start is in-flight but not yet tracked; mark it so the proxy is torn + // down the moment it's tracked after spawn, instead of coming up anyway. + stopRequestedProxies.add(cluster); + } + } + }); } /** From fe004c48456efc15328059e20ca1fefdc91a4712 Mon Sep 17 00:00:00 2001 From: yolossn Date: Wed, 22 Jul 2026 13:58:15 +0530 Subject: [PATCH 2/5] upstreamable: add cluster preOpen hook extension point Adds a preOpen hook that plugins register via registerClusterProviderPreOpen. A hook runs before a cluster's views render and its promise gates entry: while pending the app shows a connecting state, a rejection surfaces the error with a retry, and resolving opens the cluster. Plugins use it for async preparation - auth/credential setup, telemetry, resuming a stopped cluster, or starting a local proxy. Includes the redux state (preOpenHooks plus a 'preparing' map of progress messages), the RouteSwitcher connecting UI, and suppressing the transient 'Lost connection' banner while a cluster is being prepared. Signed-off-by: yolossn --- .../src/components/App/RouteSwitcher.test.tsx | 96 ++++++++++++- frontend/src/components/App/RouteSwitcher.tsx | 126 +++++++++++++++++- .../components/common/AlertNotification.tsx | 41 +++++- .../plugin/__snapshots__/pluginLib.snapshot | 1 + frontend/src/plugin/registry.tsx | 37 +++++ frontend/src/redux/clusterProviderSlice.ts | 76 ++++++++++- 6 files changed, 365 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/App/RouteSwitcher.test.tsx b/frontend/src/components/App/RouteSwitcher.test.tsx index a2f9f9ebd..bf20a6dc9 100644 --- a/frontend/src/components/App/RouteSwitcher.test.tsx +++ b/frontend/src/components/App/RouteSwitcher.test.tsx @@ -14,9 +14,14 @@ * limitations under the License. */ +import { configureStore } from '@reduxjs/toolkit'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { render } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useCluster, useClustersConf } from '../../lib/k8s'; +import { testAuth } from '../../lib/k8s/api/v1/clusterApi'; +import { addPreOpenHook } from '../../redux/clusterProviderSlice'; +import reducers from '../../redux/reducers/reducers'; import { TestContext } from '../../test'; vi.mock('../../lib/k8s', () => ({ @@ -27,10 +32,13 @@ vi.mock('../../lib/k8s', () => ({ useSelectedClusters: vi.fn(() => []), })); +vi.mock('../../lib/k8s/api/v1/clusterApi', () => ({ + testAuth: vi.fn(() => Promise.resolve(true)), +})); vi.mock('../../lib/k8s/event', () => ({ default: class Event {} })); vi.mock('../common/ObjectEventList', () => ({ default: () => null })); -import RouteSwitcher from './RouteSwitcher'; +import RouteSwitcher, { AuthRoute } from './RouteSwitcher'; // Verify RouteSwitcher renders stable route keys and handles an unset cluster. @@ -79,3 +87,85 @@ describe('RouteSwitcher', () => { ).not.toThrow(); }); }); + +describe('AuthRoute pre-open hooks', () => { + beforeEach(() => { + vi.mocked(useCluster).mockReturnValue('test-cluster'); + vi.mocked(useClustersConf).mockReturnValue({ 'test-cluster': {} } as any); + vi.mocked(testAuth) + .mockClear() + .mockResolvedValue(true as any); + }); + + afterEach(() => { + vi.mocked(useCluster).mockReturnValue(null); + }); + + // Renders a single AuthRoute for 'test-cluster' with one registered pre-open + // hook whose promise the test controls, so we can assert each preparation state. + function renderAuthRoute(hook: () => Promise) { + const store = configureStore({ reducer: reducers }); + store.dispatch(addPreOpenHook(hook)); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + return render( + + + false} + > +
cluster-content
+
+
+
+ ); + } + + it('shows the connecting dialog while a pre-open hook is pending', async () => { + const hook = vi.fn(() => new Promise(() => {})); // never settles + renderAuthRoute(hook); + + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText(/Preparing cluster/)).toBeInTheDocument(); + // The cluster's views and the auth probe are gated while preparing. + expect(screen.queryByText('cluster-content')).not.toBeInTheDocument(); + expect(testAuth).not.toHaveBeenCalled(); + }); + + it('shows an error with a Retry that refetches when a hook rejects', async () => { + let reject!: (err: Error) => void; + const hook = vi.fn(() => new Promise((_resolve, rej) => (reject = rej))); + renderAuthRoute(hook); + + await screen.findByRole('dialog'); + reject(new Error('proxy failed')); + + // Error UI: the message is shown and a Retry button appears (Retry only + // renders in the error state). + const retry = await screen.findByRole('button', { name: /Retry/i }); + expect((await screen.findAllByText(/proxy failed/)).length).toBeGreaterThan(0); + + fireEvent.click(retry); + await waitFor(() => expect(hook).toHaveBeenCalledTimes(2)); + }); + + it('does not probe auth until pre-open hooks succeed', async () => { + let resolve!: () => void; + const hook = vi.fn(() => new Promise(res => (resolve = res))); + renderAuthRoute(hook); + + await screen.findByRole('dialog'); + expect(testAuth).not.toHaveBeenCalled(); + + resolve(); + + await waitFor(() => expect(testAuth).toHaveBeenCalledWith('test-cluster')); + expect(await screen.findByText('cluster-content')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/App/RouteSwitcher.tsx b/frontend/src/components/App/RouteSwitcher.tsx index 0e69b0a50..4a6af3bdb 100644 --- a/frontend/src/components/App/RouteSwitcher.tsx +++ b/frontend/src/components/App/RouteSwitcher.tsx @@ -14,6 +14,13 @@ * limitations under the License. */ +import Box from '@mui/material/Box'; +import Button from '@mui/material/Button'; +import CircularProgress from '@mui/material/CircularProgress'; +import Dialog from '@mui/material/Dialog'; +import DialogContent from '@mui/material/DialogContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import Typography from '@mui/material/Typography'; import { useQuery } from '@tanstack/react-query'; import React, { Suspense } from 'react'; import { useTranslation } from 'react-i18next'; @@ -28,6 +35,7 @@ import { getDefaultRoutes } from '../../lib/router/getDefaultRoutes'; import { getRoutePath } from '../../lib/router/getRoutePath'; import { getRouteUseClusterURL } from '../../lib/router/getRouteUseClusterURL'; import { Route as RouteType } from '../../lib/router/Route'; +import { clearClusterPreparing, setClusterPreparing } from '../../redux/clusterProviderSlice'; import { useTypedSelector } from '../../redux/hooks'; import { uiSlice } from '../../redux/uiSlice'; import ErrorBoundary from '../common/ErrorBoundary'; @@ -155,7 +163,7 @@ interface AuthRouteProps { [otherProps: string]: any; } -function AuthRoute(props: AuthRouteProps) { +export function AuthRoute(props: AuthRouteProps) { const { children, sidebar, @@ -165,16 +173,76 @@ function AuthRoute(props: AuthRouteProps) { ...other } = props; + const { t } = useTranslation(); + const dispatch = useDispatch(); useSidebarItem(sidebar, computedMatch); const cluster = useCluster(); + + const clusters = useClustersConf(); + const currentClusterConf = (cluster && clusters ? clusters[cluster] : null) ?? null; + + // Pre-open hooks let plugins prepare a cluster (start a proxy, refresh + // credentials, write a kubeconfig context, …) before its views load. They run + // for a single opened cluster only; in multi-cluster mode we cannot attribute + // preparation to one cluster, so we skip them (mirroring the auth handling + // below). They must complete before we probe auth, since the auth probe talks + // to the cluster's API and may depend on the preparation (e.g. a proxy). + const preOpenHooks = useTypedSelector(state => state.clusterProvider.preOpenHooks); + const isSingleCluster = getSelectedClusters().length <= 1; + const preOpenEnabled = !!cluster && requiresCluster && isSingleCluster && preOpenHooks.length > 0; + const preOpenQuery = useQuery({ + queryKey: ['clusterPreOpen', cluster], + queryFn: async () => { + // Capture the cluster this run prepares so cleanup stays keyed to it even + // if the user navigates to a different cluster while hooks are running. + const preparingCluster = cluster!; + // Mark the cluster as preparing so the connecting popup shows and the + // "Lost connection" health banner is suppressed while hooks run. + dispatch(setClusterPreparing({ cluster: preparingCluster })); + const reportProgress = (message: string) => + dispatch(setClusterPreparing({ cluster: preparingCluster, message })); + try { + for (const hook of preOpenHooks) { + await hook({ + cluster: preparingCluster, + clusterConf: currentClusterConf, + reportProgress, + }); + } + return true; + } finally { + // Clear deterministically for the cluster we prepared (on success or + // error) so a mid-run navigation can't leave a stale "preparing" entry. + dispatch(clearClusterPreparing(preparingCluster)); + } + }, + enabled: preOpenEnabled, + retry: 0, + // Prepare a cluster once per open: staleTime keeps hooks from re-running + // while the cluster page stays mounted, and gcTime: 0 evicts the result on + // unmount so leaving and re-opening the cluster re-runs preparation (rather + // than reusing a cached success and skipping a proxy that may have died). + staleTime: Infinity, + gcTime: 0, + }); + + // The latest progress message a hook reported for this cluster (drives the + // connecting popup's text). Undefined when the cluster isn't preparing. + const preparingMessage = useTypedSelector(state => + cluster ? state.clusterProvider.preparing?.[cluster] : undefined + ); + + // (The preparing flag is cleared in the query's `finally` above, keyed to the + // cluster that was prepared — deterministic even across mid-run navigation.) + const query = useQuery({ queryKey: ['auth', cluster], queryFn: () => testAuth(cluster!), - enabled: !!cluster && requiresAuth, + // Wait for pre-open preparation before probing auth against the cluster. + enabled: !!cluster && requiresAuth && (!preOpenEnabled || preOpenQuery.isSuccess), retry: 0, }); - const clusters = useClustersConf(); const currentCluster = getCluster(); const clusterConf = currentCluster && clusters ? clusters[currentCluster] : null; const authError = query.error as any; @@ -193,6 +261,58 @@ function AuthRoute(props: AuthRouteProps) { } function getRenderer({ location }: RouteProps) { + // Gate the cluster's views on any registered pre-open preparation. This runs + // before the auth check below, and for both auth and no-auth cluster routes. + if (preOpenEnabled) { + if (preOpenQuery.isError) { + const detail = + preOpenQuery.error instanceof Error + ? preOpenQuery.error.message + : String(preOpenQuery.error); + return ( + + {detail} + + + + + } + /> + ); + } + if (!preOpenQuery.isSuccess) { + // A modal "connecting" popup (rather than a bare page loader) so opening + // the cluster reads as a deliberate connect step. Only the dialog renders + // while preparation is pending; the cluster's views render once it + // succeeds (this renderer returns `children` on success, below). + return ( + + + {t('translation|Connecting to "{{cluster}}"', { cluster })} + + + + + + + ); + } + } + if (!requiresAuth) { return children; } diff --git a/frontend/src/components/common/AlertNotification.tsx b/frontend/src/components/common/AlertNotification.tsx index 34f293656..165fde8bf 100644 --- a/frontend/src/components/common/AlertNotification.tsx +++ b/frontend/src/components/common/AlertNotification.tsx @@ -24,12 +24,19 @@ import { getCluster } from '../../lib/cluster'; import { testClusterHealth } from '../../lib/k8s/api/v1/clusterApi'; import { getRoute } from '../../lib/router/getRoute'; import { getRoutePath } from '../../lib/router/getRoutePath'; +import { useTypedSelector } from '../../redux/hooks'; // in ms const NETWORK_STATUS_CHECK_TIME = 5000; export interface PureAlertNotificationProps { checkerFunction(): Promise; + /** + * When true, the alert is suppressed. Used while a cluster is being prepared + * by pre-open hooks (e.g. a proxy is still starting), so the transient + * unreachability during connect doesn't flash a "Lost connection" banner. + */ + suppress?: boolean; } // Routes where we don't show the alert notification. @@ -37,7 +44,7 @@ export interface PureAlertNotificationProps { // some other reason. const ROUTES_WITHOUT_ALERT = ['login', 'token', 'settingsCluster']; -export function PureAlertNotification({ checkerFunction }: PureAlertNotificationProps) { +export function PureAlertNotification({ checkerFunction, suppress }: PureAlertNotificationProps) { const [networkStatusCheckTimeFactor, setNetworkStatusCheckTimeFactor] = React.useState(0); const [error, setError] = React.useState(null); @@ -46,6 +53,13 @@ export function PureAlertNotification({ checkerFunction }: PureAlertNotification function registerSetInterval(): NodeJS.Timeout { return setInterval(() => { + // While the cluster is being prepared (proxy starting, etc.) transient + // unreachability is expected — don't alarm the user. + if (suppress) { + setError(null); + return; + } + if (!window.navigator.onLine) { setError(t('translation|Offline') as string); return; @@ -78,13 +92,21 @@ export function PureAlertNotification({ checkerFunction }: PureAlertNotification } }, [pathname]); + // Clear any standing error the moment preparation starts, so the banner + // disappears immediately rather than on the next poll tick. + React.useEffect(() => { + if (suppress) { + setError(null); + } + }, [suppress]); + React.useEffect( () => { const id = registerSetInterval(); return () => clearInterval(id); }, // eslint-disable-next-line - [networkStatusCheckTimeFactor] + [networkStatusCheckTimeFactor, suppress] ); const showOnRoute = React.useMemo(() => { @@ -102,7 +124,7 @@ export function PureAlertNotification({ checkerFunction }: PureAlertNotification return true; }, [pathname]); - if (!error || !showOnRoute) { + if (!error || !showOnRoute || suppress) { return null; } @@ -162,5 +184,16 @@ export function PureAlertNotification({ checkerFunction }: PureAlertNotification } export default function AlertNotification() { - return ; + // Re-render on navigation so the URL-derived cluster below stays current: + // getCluster() reads the location, which Redux state changes alone don't track, + // so without this the selector could keep a stale cluster after a route change. + useLocation(); + // Suppress the banner while the current cluster is being prepared by pre-open + // hooks (the connecting popup owns the UX during that window). + const isPreparing = useTypedSelector(state => { + const current = getCluster(); + const preparing = state.clusterProvider.preparing; + return !!current && !!preparing && Object.prototype.hasOwnProperty.call(preparing, current); + }); + return ; } diff --git a/frontend/src/plugin/__snapshots__/pluginLib.snapshot b/frontend/src/plugin/__snapshots__/pluginLib.snapshot index f25ecbb60..ee9f50cf8 100644 --- a/frontend/src/plugin/__snapshots__/pluginLib.snapshot +++ b/frontend/src/plugin/__snapshots__/pluginLib.snapshot @@ -625,6 +625,7 @@ "registerClusterChooser": [Function], "registerClusterProviderDialog": [Function], "registerClusterProviderMenuItem": [Function], + "registerClusterProviderPreOpen": [Function], "registerClusterStatus": [Function], "registerCustomCreateProject": [Function], "registerDetailsViewHeaderAction": [Function], diff --git a/frontend/src/plugin/registry.tsx b/frontend/src/plugin/registry.tsx index 82cff595d..3c97042aa 100644 --- a/frontend/src/plugin/registry.tsx +++ b/frontend/src/plugin/registry.tsx @@ -68,6 +68,8 @@ import { addClusterStatus, addDialog, addMenuItem, + addPreOpenHook, + ClusterPreOpenHook, ClusterProviderInfo, ClusterStatusComponent, DialogComponent, @@ -993,6 +995,41 @@ export function registerAddClusterProvider(item: ClusterProviderInfo) { store.dispatch(addAddClusterProvider(item)); } +/** + * Register a hook that runs once, before a cluster's views are rendered. + * + * Use it for any asynchronous work a cluster needs before it can be used — + * starting a proxy/tunnel, refreshing credentials, writing a kubeconfig + * context, warming a cache, and so on. The hook runs for every cluster that is + * opened, so a hook that only applies to some clusters should inspect the + * context and resolve immediately for the ones it does not own. + * + * The returned promise gates entry to the cluster: while it is pending the app + * shows a neutral loading state, and if it rejects the thrown error's message + * is shown to the user with a retry affordance. Multiple registered hooks run + * sequentially, in registration order; the cluster opens once all of them + * resolve, and the first rejection stops the rest. + * + * @param hook - The hook to run before a cluster is opened. + * + * @example + * + * ```ts + * import { registerClusterProviderPreOpen } from '@kinvolk/headlamp-plugin/lib'; + * + * registerClusterProviderPreOpen(async ({ cluster }) => { + * if (!isMyProviderCluster(cluster)) { + * return; // no-op for clusters this plugin does not own + * } + * await startProxyFor(cluster); + * await waitUntilReachable(cluster); // throw to block opening with an error + * }); + * ``` + */ +export function registerClusterProviderPreOpen(hook: ClusterPreOpenHook) { + store.dispatch(addPreOpenHook(hook)); +} + /** * Add a new theme that will be available in the settings. * Theme name should be unique diff --git a/frontend/src/redux/clusterProviderSlice.ts b/frontend/src/redux/clusterProviderSlice.ts index 2c2fb6b12..73da5f529 100644 --- a/frontend/src/redux/clusterProviderSlice.ts +++ b/frontend/src/redux/clusterProviderSlice.ts @@ -38,6 +38,42 @@ export type DialogComponent = (props: DialogProps) => React.ReactElement | null; export type MenuItemComponent = (props: MenuItemProps) => React.ReactElement | null; export type ClusterStatusComponent = (props: ClusterStatusProps) => React.ReactElement | null; +/** + * Context passed to a {@link ClusterPreOpenHook} when a cluster is about to be opened. + */ +export interface ClusterPreOpenContext { + /** The name of the cluster being opened. */ + cluster: string; + /** + * The cluster's configuration, as known to the app, or `null` if unavailable. + * Typed `unknown` so this slice does not depend on the k8s cluster + * types while still requiring hook authors to narrow before use. + */ + clusterConf: unknown; + /** + * Reports human-readable progress for the "connecting" popup shown while the + * cluster is being prepared (e.g. "Starting proxy…", "Verifying connection…"). + * Optional — hooks that don't report progress just show a generic message. + */ + reportProgress?: (message: string) => void; +} + +/** + * A hook run once, before a cluster's views are rendered. + * + * Use it to perform any asynchronous preparation a cluster needs before it can + * be used — starting a proxy/tunnel, refreshing credentials, writing a + * kubeconfig context, warming a cache, etc. Hooks run for every cluster, so a + * hook that only applies to certain clusters should inspect the context and + * resolve immediately for the ones it does not own. + * + * The returned promise gates entry to the cluster: while it is pending the app + * shows a neutral loading state, and if it rejects the thrown error's message + * is surfaced to the user with a retry affordance. Resolve to allow the cluster + * to open. + */ +export type ClusterPreOpenHook = (context: ClusterPreOpenContext) => Promise; + /** * Information about a cluster provider, that is shown on the add cluster page. */ @@ -61,6 +97,15 @@ export interface ClusterProviderSliceState { clusterProviders: ClusterProviderInfo[]; /** Cluster statuses for the Home page. */ clusterStatuses: ClusterStatusComponent[]; + /** Hooks run before a cluster is opened, to prepare it (e.g. start a proxy). */ + preOpenHooks: ClusterPreOpenHook[]; + /** + * Clusters currently being prepared by pre-open hooks, mapped to the latest + * progress message (empty string until a hook reports one). Presence in this + * map means "preparation in progress" — used to show the connecting popup and + * to suppress the app's "Lost connection" health banner during preparation. + */ + preparing: Record; } export const initialState: ClusterProviderSliceState = { @@ -68,6 +113,8 @@ export const initialState: ClusterProviderSliceState = { dialogs: [], clusterProviders: [], clusterStatuses: [], + preOpenHooks: [], + preparing: Object.create(null), }; const clusterProviderSlice = createSlice({ @@ -86,10 +133,35 @@ const clusterProviderSlice = createSlice({ addClusterStatus(state, action: PayloadAction) { state.clusterStatuses.push(action.payload); }, + addPreOpenHook(state, action: PayloadAction) { + state.preOpenHooks.push(action.payload); + }, + /** Marks a cluster as being prepared, optionally with a progress message. */ + setClusterPreparing(state, action: PayloadAction<{ cluster: string; message?: string }>) { + // Ensure a null-prototype map, including when rehydrating persisted/older + // state that predates this field or was stored as a plain object. + if (!state.preparing || Object.getPrototypeOf(state.preparing) !== null) { + state.preparing = Object.assign(Object.create(null), state.preparing); + } + state.preparing[action.payload.cluster] = action.payload.message ?? ''; + }, + /** Clears a cluster's preparing state once its pre-open hooks settle. */ + clearClusterPreparing(state, action: PayloadAction) { + if (state.preparing) { + delete state.preparing[action.payload]; + } + }, }, }); -export const { addDialog, addMenuItem, addAddClusterProvider, addClusterStatus } = - clusterProviderSlice.actions; +export const { + addDialog, + addMenuItem, + addAddClusterProvider, + addClusterStatus, + addPreOpenHook, + setClusterPreparing, + clearClusterPreparing, +} = clusterProviderSlice.actions; export default clusterProviderSlice.reducer; From 856b921eb5b966c95666dfdfe5012aeb192d9b99 Mon Sep 17 00:00:00 2001 From: yolossn Date: Wed, 22 Jul 2026 14:04:30 +0530 Subject: [PATCH 3/5] upstreamable: keep built-in Delete when a plugin registers a cluster menu item The built-in Delete item was gated on 'no plugin menu items registered', so registering any cluster provider menu item hid Delete for every cluster. The gate assumed a provider that adds menu items also provides its own delete, but plugin menu items are additive. Show Delete based on the cluster's source/permissions instead. Signed-off-by: yolossn --- .../App/Home/ClusterContextMenu.tsx | 35 +++++++++++-------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/App/Home/ClusterContextMenu.tsx b/frontend/src/components/App/Home/ClusterContextMenu.tsx index f50bd7f65..fbead21ba 100644 --- a/frontend/src/components/App/Home/ClusterContextMenu.tsx +++ b/frontend/src/components/App/Home/ClusterContextMenu.tsx @@ -163,20 +163,27 @@ export default function ClusterContextMenu({ cluster }: ClusterContextMenuProps) > {t('translation|Settings')} - {(!menuItems || menuItems.length === 0) && - ((cluster.meta_data?.source === 'dynamic_cluster' && - (helpers.isElectron() || isDynamicClusterEnabled)) || - (cluster.meta_data?.source === 'kubeconfig' && - (helpers.isElectron() || allowKubeconfigChanges))) && ( - { - setOpenConfirmDialog('deleteDynamic'); - handleMenuClose(); - }} - > - {t('translation|Delete')} - - )} + {/* + Portions (c) Microsoft Corp.: the built-in Delete used to be gated on + `(!menuItems || menuItems.length === 0)`, which hid it for every cluster + as soon as any plugin registered a cluster menu item (e.g. the AKS + Hybrid & Edge start/stop-proxy action). Plugin menu items are additive + and don't provide their own delete, so Delete is shown based purely on + the cluster source/permissions instead. + */} + {((cluster.meta_data?.source === 'dynamic_cluster' && + (helpers.isElectron() || isDynamicClusterEnabled)) || + (cluster.meta_data?.source === 'kubeconfig' && + (helpers.isElectron() || allowKubeconfigChanges))) && ( + { + setOpenConfirmDialog('deleteDynamic'); + handleMenuClose(); + }} + > + {t('translation|Delete')} + + )} {menuItems.map((Item, index) => { return ( Date: Wed, 22 Jul 2026 13:58:34 +0530 Subject: [PATCH 4/5] i18n: regenerate locales for preOpen strings Regenerated translation catalogs for the new connecting/preparing strings added by the cluster preOpen hook UI. Signed-off-by: yolossn --- frontend/src/i18n/locales/ar/translation.json | 10 ++++++---- frontend/src/i18n/locales/bn/translation.json | 10 ++++++---- frontend/src/i18n/locales/cs/translation.json | 9 +++++---- frontend/src/i18n/locales/de/translation.json | 9 +++++---- frontend/src/i18n/locales/en/translation.json | 9 +++++---- frontend/src/i18n/locales/es/translation.json | 9 +++++---- frontend/src/i18n/locales/fr/translation.json | 9 +++++---- frontend/src/i18n/locales/he/translation.json | 10 ++++++---- frontend/src/i18n/locales/hi/translation.json | 9 +++++---- frontend/src/i18n/locales/hu/translation.json | 9 +++++---- frontend/src/i18n/locales/id/translation.json | 9 +++++---- frontend/src/i18n/locales/it/translation.json | 9 +++++---- frontend/src/i18n/locales/ja/translation.json | 9 +++++---- frontend/src/i18n/locales/ko/translation.json | 9 +++++---- frontend/src/i18n/locales/nl/translation.json | 9 +++++---- frontend/src/i18n/locales/pl/translation.json | 9 +++++---- frontend/src/i18n/locales/pt-BR/translation.json | 9 +++++---- frontend/src/i18n/locales/pt-PT/translation.json | 9 +++++---- frontend/src/i18n/locales/ru/translation.json | 9 +++++---- frontend/src/i18n/locales/sv/translation.json | 9 +++++---- frontend/src/i18n/locales/ta/translation.json | 9 +++++---- frontend/src/i18n/locales/tr/translation.json | 9 +++++---- frontend/src/i18n/locales/ur/translation.json | 10 ++++++---- frontend/src/i18n/locales/zh-Hans/translation.json | 9 +++++---- frontend/src/i18n/locales/zh-Hant/translation.json | 9 +++++---- 25 files changed, 129 insertions(+), 100 deletions(-) diff --git a/frontend/src/i18n/locales/ar/translation.json b/frontend/src/i18n/locales/ar/translation.json index 269b6c54c..f6f83e0a3 100644 --- a/frontend/src/i18n/locales/ar/translation.json +++ b/frontend/src/i18n/locales/ar/translation.json @@ -136,6 +136,10 @@ "Error deleting plugin {{ itemName }}.": "حدث خطأ أثناء حذف الإضافة {{ itemName }}", "Uh-oh! Something went wrong.": "عذرًا! حدث خطأ ما.", "Error loading {{ routeName }}": "حدث خطأ أثناء تحميل {{ routeName }}", + "Could not open cluster": "", + "Retry": "", + "Connecting to \"{{cluster}}\"": "", + "Preparing cluster…": "", "Cluster name must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character.": "يجب أن يحتوي اسم الكتلة فقط على أحرف وأرقام صغيرة أو '-'، ويجب أن يبدأ وينتهي بحرف أو رقم.", "Error": "خطأ", "Okay": "حسنًا", @@ -288,12 +292,14 @@ "Failed to copy to clipboard": "فشل النسخ إلى الحافظة", "Copy": "نسخ", "Open Issue on GitHub": "فتح مشكلة على GitHub", + "Previous": "السابق", "Scroll to bottom": "", "Container": "الحاوية", "Lines": "الأسطر", "Severity": "الخطورة", "Timestamps": "الطوابع الزمنية", "Wrap lines": "", + "Log Level": "", "All levels": "", "Find": "بحث", "Download": "تنزيل", @@ -490,7 +496,6 @@ "Created": "تم الإنشاء", "Images": "الصور", "Current": "الحالي", - "Previous": "السابق", "Rolling back {{ itemName }} to previous version…": "جارٍ التراجع عن {{ itemName }} إلى الإصدار السابق…", "Cancelled rollback of {{ itemName }}.": "تم إلغاء التراجع عن {{ itemName }}", "Rolled back {{ itemName }} to previous version.": "تم التراجع عن {{ itemName }} إلى الإصدار السابق", @@ -752,9 +757,6 @@ "Namespace Name": "", "A namespace with this name already exists on one or more selected clusters": "", "Enter a name for the new namespace": "", - "Type or select a namespace": "اكتب أو اختر نطاقًا", - "Select existing or type to create a new namespace": "اختر نطاقًا موجودًا أو اكتب لإنشاء نطاق جديد", - "No available namespaces - you can type a custom name": "لا توجد نطاقات متاحة - يمكنك كتابة اسم مخصص", "Select an existing namespace to use as a project": "", "Create a new namespace and use it as a project": "", "Create a Project": "إنشاء مشروع", diff --git a/frontend/src/i18n/locales/bn/translation.json b/frontend/src/i18n/locales/bn/translation.json index ae728050a..647287e50 100644 --- a/frontend/src/i18n/locales/bn/translation.json +++ b/frontend/src/i18n/locales/bn/translation.json @@ -136,6 +136,10 @@ "Error deleting plugin {{ itemName }}.": "Plugin delete করতে Error {{ itemName }}।", "Uh-oh! Something went wrong.": "আহ-ওহ! ", "Error loading {{ routeName }}": "Loading Error {{ routeName }}", + "Could not open cluster": "", + "Retry": "", + "Connecting to \"{{cluster}}\"": "", + "Preparing cluster…": "", "Cluster name must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character.": "Clusterের নামটিতে অবশ্যই কেবল ছোট আলফানিউমেরিক অক্ষর বা '-' থাকতে হবে এবং এটি অবশ্যই একটি আলফানিউমেরিক চরিত্রের সাথে শুরু এবং শেষ করতে হবে।", "Error": "Error", "Okay": "ঠিক আছে", @@ -284,12 +288,14 @@ "Failed to copy to clipboard": "ক্লিপবোর্ডে কপি করতে ব্যর্থ", "Copy": "Copy", "Open Issue on GitHub": "GitHub-এ Issue খুলুন", + "Previous": "পূর্ববর্তী", "Scroll to bottom": "", "Container": "Container", "Lines": "Lines", "Severity": "তীব্রতা", "Timestamps": "Timestamps", "Wrap lines": "", + "Log Level": "", "All levels": "", "Find": "সন্ধান করুন", "Download": "Download", @@ -474,7 +480,6 @@ "Created": "তৈরি হয়েছে", "Images": "ইমেজ", "Current": "কারেন্ট", - "Previous": "পূর্ববর্তী", "Rolling back {{ itemName }} to previous version…": "{{ itemName }} পূর্ববর্তী Version-এ ফিরিয়ে নেওয়া হচ্ছে…", "Cancelled rollback of {{ itemName }}.": "{{ itemName }} রোলব্যাক বাতিল।", "Rolled back {{ itemName }} to previous version.": "{{ itemName }} পূর্ববর্তী Version-এ ফেরানো হয়েছে।", @@ -732,9 +737,6 @@ "Namespace Name": "", "A namespace with this name already exists on one or more selected clusters": "", "Enter a name for the new namespace": "", - "Type or select a namespace": "একটি নেমস্পেস টাইপ করুন বা নির্বাচন করুন", - "Select existing or type to create a new namespace": "বিদ্যমান নির্বাচন করুন বা নতুন নেমস্পেস তৈরি করতে টাইপ করুন", - "No available namespaces - you can type a custom name": "কোনো উপলব্ধ নেমস্পেস নেই - আপনি একটি কাস্টম নাম টাইপ করতে পারেন", "Select an existing namespace to use as a project": "", "Create a new namespace and use it as a project": "", "Create a Project": "একটি প্রকল্প তৈরি করুন", diff --git a/frontend/src/i18n/locales/cs/translation.json b/frontend/src/i18n/locales/cs/translation.json index cb6d3f2ba..9f1da2f9e 100644 --- a/frontend/src/i18n/locales/cs/translation.json +++ b/frontend/src/i18n/locales/cs/translation.json @@ -136,6 +136,10 @@ "Error deleting plugin {{ itemName }}.": "", "Uh-oh! Something went wrong.": "", "Error loading {{ routeName }}": "", + "Could not open cluster": "", + "Retry": "", + "Connecting to \"{{cluster}}\"": "", + "Preparing cluster…": "", "Cluster name must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character.": "", "Error": "", "Okay": "", @@ -286,6 +290,7 @@ "Failed to copy to clipboard": "", "Copy": "", "Open Issue on GitHub": "", + "Previous": "", "Scroll to bottom": "", "Container": "", "Lines": "", @@ -483,7 +488,6 @@ "Created": "", "Images": "", "Current": "", - "Previous": "", "Rolling back {{ itemName }} to previous version…": "", "Cancelled rollback of {{ itemName }}.": "", "Rolled back {{ itemName }} to previous version.": "", @@ -743,9 +747,6 @@ "Namespace Name": "", "A namespace with this name already exists on one or more selected clusters": "", "Enter a name for the new namespace": "", - "Type or select a namespace": "", - "Select existing or type to create a new namespace": "", - "No available namespaces - you can type a custom name": "", "Select an existing namespace to use as a project": "", "Create a new namespace and use it as a project": "", "Create a Project": "", diff --git a/frontend/src/i18n/locales/de/translation.json b/frontend/src/i18n/locales/de/translation.json index 0a04dc188..b6ba2611e 100644 --- a/frontend/src/i18n/locales/de/translation.json +++ b/frontend/src/i18n/locales/de/translation.json @@ -136,6 +136,10 @@ "Error deleting plugin {{ itemName }}.": "", "Uh-oh! Something went wrong.": "", "Error loading {{ routeName }}": "", + "Could not open cluster": "", + "Retry": "", + "Connecting to \"{{cluster}}\"": "", + "Preparing cluster…": "", "Cluster name must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character.": "", "Error": "", "Okay": "", @@ -284,6 +288,7 @@ "Failed to copy to clipboard": "", "Copy": "", "Open Issue on GitHub": "", + "Previous": "", "Scroll to bottom": "", "Container": "", "Lines": "", @@ -475,7 +480,6 @@ "Created": "", "Images": "", "Current": "", - "Previous": "", "Rolling back {{ itemName }} to previous version…": "", "Cancelled rollback of {{ itemName }}.": "", "Rolled back {{ itemName }} to previous version.": "", @@ -733,9 +737,6 @@ "Namespace Name": "", "A namespace with this name already exists on one or more selected clusters": "", "Enter a name for the new namespace": "", - "Type or select a namespace": "", - "Select existing or type to create a new namespace": "", - "No available namespaces - you can type a custom name": "", "Select an existing namespace to use as a project": "", "Create a new namespace and use it as a project": "", "Create a Project": "", diff --git a/frontend/src/i18n/locales/en/translation.json b/frontend/src/i18n/locales/en/translation.json index 132eafa3f..9effcc7f9 100644 --- a/frontend/src/i18n/locales/en/translation.json +++ b/frontend/src/i18n/locales/en/translation.json @@ -136,6 +136,10 @@ "Error deleting plugin {{ itemName }}.": "Error deleting plugin {{ itemName }}.", "Uh-oh! Something went wrong.": "Uh-oh! Something went wrong.", "Error loading {{ routeName }}": "Error loading {{ routeName }}", + "Could not open cluster": "Could not open cluster", + "Retry": "Retry", + "Connecting to \"{{cluster}}\"": "Connecting to \"{{cluster}}\"", + "Preparing cluster…": "Preparing cluster…", "Cluster name must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character.": "Cluster name must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character.", "Error": "Error", "Okay": "Okay", @@ -284,6 +288,7 @@ "Failed to copy to clipboard": "Failed to copy to clipboard", "Copy": "Copy", "Open Issue on GitHub": "Open Issue on GitHub", + "Previous": "Previous", "Scroll to bottom": "Scroll to bottom", "Container": "Container", "Lines": "Lines", @@ -475,7 +480,6 @@ "Created": "Created", "Images": "Images", "Current": "Current", - "Previous": "Previous", "Rolling back {{ itemName }} to previous version…": "Rolling back {{ itemName }} to previous version…", "Cancelled rollback of {{ itemName }}.": "Cancelled rollback of {{ itemName }}.", "Rolled back {{ itemName }} to previous version.": "Rolled back {{ itemName }} to previous version.", @@ -733,9 +737,6 @@ "Namespace Name": "Namespace Name", "A namespace with this name already exists on one or more selected clusters": "A namespace with this name already exists on one or more selected clusters", "Enter a name for the new namespace": "Enter a name for the new namespace", - "Type or select a namespace": "Type or select a namespace", - "Select existing or type to create a new namespace": "Select existing or type to create a new namespace", - "No available namespaces - you can type a custom name": "No available namespaces - you can type a custom name", "Select an existing namespace to use as a project": "Select an existing namespace to use as a project", "Create a new namespace and use it as a project": "Create a new namespace and use it as a project", "Create a Project": "Create a Project", diff --git a/frontend/src/i18n/locales/es/translation.json b/frontend/src/i18n/locales/es/translation.json index ff3468578..45895be37 100644 --- a/frontend/src/i18n/locales/es/translation.json +++ b/frontend/src/i18n/locales/es/translation.json @@ -136,6 +136,10 @@ "Error deleting plugin {{ itemName }}.": "", "Uh-oh! Something went wrong.": "", "Error loading {{ routeName }}": "", + "Could not open cluster": "", + "Retry": "", + "Connecting to \"{{cluster}}\"": "", + "Preparing cluster…": "", "Cluster name must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character.": "", "Error": "", "Okay": "", @@ -285,6 +289,7 @@ "Failed to copy to clipboard": "", "Copy": "", "Open Issue on GitHub": "", + "Previous": "", "Scroll to bottom": "", "Container": "", "Lines": "", @@ -479,7 +484,6 @@ "Created": "", "Images": "", "Current": "", - "Previous": "", "Rolling back {{ itemName }} to previous version…": "", "Cancelled rollback of {{ itemName }}.": "", "Rolled back {{ itemName }} to previous version.": "", @@ -738,9 +742,6 @@ "Namespace Name": "", "A namespace with this name already exists on one or more selected clusters": "", "Enter a name for the new namespace": "", - "Type or select a namespace": "", - "Select existing or type to create a new namespace": "", - "No available namespaces - you can type a custom name": "", "Select an existing namespace to use as a project": "", "Create a new namespace and use it as a project": "", "Create a Project": "", diff --git a/frontend/src/i18n/locales/fr/translation.json b/frontend/src/i18n/locales/fr/translation.json index ff3468578..45895be37 100644 --- a/frontend/src/i18n/locales/fr/translation.json +++ b/frontend/src/i18n/locales/fr/translation.json @@ -136,6 +136,10 @@ "Error deleting plugin {{ itemName }}.": "", "Uh-oh! Something went wrong.": "", "Error loading {{ routeName }}": "", + "Could not open cluster": "", + "Retry": "", + "Connecting to \"{{cluster}}\"": "", + "Preparing cluster…": "", "Cluster name must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character.": "", "Error": "", "Okay": "", @@ -285,6 +289,7 @@ "Failed to copy to clipboard": "", "Copy": "", "Open Issue on GitHub": "", + "Previous": "", "Scroll to bottom": "", "Container": "", "Lines": "", @@ -479,7 +484,6 @@ "Created": "", "Images": "", "Current": "", - "Previous": "", "Rolling back {{ itemName }} to previous version…": "", "Cancelled rollback of {{ itemName }}.": "", "Rolled back {{ itemName }} to previous version.": "", @@ -738,9 +742,6 @@ "Namespace Name": "", "A namespace with this name already exists on one or more selected clusters": "", "Enter a name for the new namespace": "", - "Type or select a namespace": "", - "Select existing or type to create a new namespace": "", - "No available namespaces - you can type a custom name": "", "Select an existing namespace to use as a project": "", "Create a new namespace and use it as a project": "", "Create a Project": "", diff --git a/frontend/src/i18n/locales/he/translation.json b/frontend/src/i18n/locales/he/translation.json index c20df2921..1cdcf06c5 100644 --- a/frontend/src/i18n/locales/he/translation.json +++ b/frontend/src/i18n/locales/he/translation.json @@ -136,6 +136,10 @@ "Error deleting plugin {{ itemName }}.": "Error deleting plugin {{ itemName }}.", "Uh-oh! Something went wrong.": "Uh-oh! Something went wrong.", "Error loading {{ routeName }}": "Error loading {{ routeName }}", + "Could not open cluster": "", + "Retry": "", + "Connecting to \"{{cluster}}\"": "", + "Preparing cluster…": "", "Cluster name must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character.": "Cluster name must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character.", "Error": "Error", "Okay": "Okay", @@ -285,12 +289,14 @@ "Failed to copy to clipboard": "Failed to copy to clipboard", "Copy": "Copy", "Open Issue on GitHub": "Open Issue on GitHub", + "Previous": "Previous", "Scroll to bottom": "", "Container": "Container", "Lines": "Lines", "Severity": "Severity", "Timestamps": "Timestamps", "Wrap lines": "", + "Log Level": "", "All levels": "", "Find": "Find", "Download": "Download", @@ -478,7 +484,6 @@ "Created": "Created", "Images": "Images", "Current": "Current", - "Previous": "Previous", "Rolling back {{ itemName }} to previous version…": "Rolling back {{ itemName }} to previous version…", "Cancelled rollback of {{ itemName }}.": "Cancelled rollback of {{ itemName }}.", "Rolled back {{ itemName }} to previous version.": "Rolled back {{ itemName }} to previous version.", @@ -737,9 +742,6 @@ "Namespace Name": "", "A namespace with this name already exists on one or more selected clusters": "", "Enter a name for the new namespace": "", - "Type or select a namespace": "Type or select a namespace", - "Select existing or type to create a new namespace": "Select existing or type to create a new namespace", - "No available namespaces - you can type a custom name": "No available namespaces - you can type a custom name", "Select an existing namespace to use as a project": "", "Create a new namespace and use it as a project": "", "Create a Project": "Create a Project", diff --git a/frontend/src/i18n/locales/hi/translation.json b/frontend/src/i18n/locales/hi/translation.json index 0a04dc188..b6ba2611e 100644 --- a/frontend/src/i18n/locales/hi/translation.json +++ b/frontend/src/i18n/locales/hi/translation.json @@ -136,6 +136,10 @@ "Error deleting plugin {{ itemName }}.": "", "Uh-oh! Something went wrong.": "", "Error loading {{ routeName }}": "", + "Could not open cluster": "", + "Retry": "", + "Connecting to \"{{cluster}}\"": "", + "Preparing cluster…": "", "Cluster name must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character.": "", "Error": "", "Okay": "", @@ -284,6 +288,7 @@ "Failed to copy to clipboard": "", "Copy": "", "Open Issue on GitHub": "", + "Previous": "", "Scroll to bottom": "", "Container": "", "Lines": "", @@ -475,7 +480,6 @@ "Created": "", "Images": "", "Current": "", - "Previous": "", "Rolling back {{ itemName }} to previous version…": "", "Cancelled rollback of {{ itemName }}.": "", "Rolled back {{ itemName }} to previous version.": "", @@ -733,9 +737,6 @@ "Namespace Name": "", "A namespace with this name already exists on one or more selected clusters": "", "Enter a name for the new namespace": "", - "Type or select a namespace": "", - "Select existing or type to create a new namespace": "", - "No available namespaces - you can type a custom name": "", "Select an existing namespace to use as a project": "", "Create a new namespace and use it as a project": "", "Create a Project": "", diff --git a/frontend/src/i18n/locales/hu/translation.json b/frontend/src/i18n/locales/hu/translation.json index 0a04dc188..b6ba2611e 100644 --- a/frontend/src/i18n/locales/hu/translation.json +++ b/frontend/src/i18n/locales/hu/translation.json @@ -136,6 +136,10 @@ "Error deleting plugin {{ itemName }}.": "", "Uh-oh! Something went wrong.": "", "Error loading {{ routeName }}": "", + "Could not open cluster": "", + "Retry": "", + "Connecting to \"{{cluster}}\"": "", + "Preparing cluster…": "", "Cluster name must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character.": "", "Error": "", "Okay": "", @@ -284,6 +288,7 @@ "Failed to copy to clipboard": "", "Copy": "", "Open Issue on GitHub": "", + "Previous": "", "Scroll to bottom": "", "Container": "", "Lines": "", @@ -475,7 +480,6 @@ "Created": "", "Images": "", "Current": "", - "Previous": "", "Rolling back {{ itemName }} to previous version…": "", "Cancelled rollback of {{ itemName }}.": "", "Rolled back {{ itemName }} to previous version.": "", @@ -733,9 +737,6 @@ "Namespace Name": "", "A namespace with this name already exists on one or more selected clusters": "", "Enter a name for the new namespace": "", - "Type or select a namespace": "", - "Select existing or type to create a new namespace": "", - "No available namespaces - you can type a custom name": "", "Select an existing namespace to use as a project": "", "Create a new namespace and use it as a project": "", "Create a Project": "", diff --git a/frontend/src/i18n/locales/id/translation.json b/frontend/src/i18n/locales/id/translation.json index dedcc049b..3c2db266d 100644 --- a/frontend/src/i18n/locales/id/translation.json +++ b/frontend/src/i18n/locales/id/translation.json @@ -136,6 +136,10 @@ "Error deleting plugin {{ itemName }}.": "", "Uh-oh! Something went wrong.": "", "Error loading {{ routeName }}": "", + "Could not open cluster": "", + "Retry": "", + "Connecting to \"{{cluster}}\"": "", + "Preparing cluster…": "", "Cluster name must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character.": "", "Error": "", "Okay": "", @@ -283,6 +287,7 @@ "Failed to copy to clipboard": "", "Copy": "", "Open Issue on GitHub": "", + "Previous": "", "Scroll to bottom": "", "Container": "", "Lines": "", @@ -471,7 +476,6 @@ "Created": "", "Images": "", "Current": "", - "Previous": "", "Rolling back {{ itemName }} to previous version…": "", "Cancelled rollback of {{ itemName }}.": "", "Rolled back {{ itemName }} to previous version.": "", @@ -728,9 +732,6 @@ "Namespace Name": "", "A namespace with this name already exists on one or more selected clusters": "", "Enter a name for the new namespace": "", - "Type or select a namespace": "", - "Select existing or type to create a new namespace": "", - "No available namespaces - you can type a custom name": "", "Select an existing namespace to use as a project": "", "Create a new namespace and use it as a project": "", "Create a Project": "", diff --git a/frontend/src/i18n/locales/it/translation.json b/frontend/src/i18n/locales/it/translation.json index ff3468578..45895be37 100644 --- a/frontend/src/i18n/locales/it/translation.json +++ b/frontend/src/i18n/locales/it/translation.json @@ -136,6 +136,10 @@ "Error deleting plugin {{ itemName }}.": "", "Uh-oh! Something went wrong.": "", "Error loading {{ routeName }}": "", + "Could not open cluster": "", + "Retry": "", + "Connecting to \"{{cluster}}\"": "", + "Preparing cluster…": "", "Cluster name must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character.": "", "Error": "", "Okay": "", @@ -285,6 +289,7 @@ "Failed to copy to clipboard": "", "Copy": "", "Open Issue on GitHub": "", + "Previous": "", "Scroll to bottom": "", "Container": "", "Lines": "", @@ -479,7 +484,6 @@ "Created": "", "Images": "", "Current": "", - "Previous": "", "Rolling back {{ itemName }} to previous version…": "", "Cancelled rollback of {{ itemName }}.": "", "Rolled back {{ itemName }} to previous version.": "", @@ -738,9 +742,6 @@ "Namespace Name": "", "A namespace with this name already exists on one or more selected clusters": "", "Enter a name for the new namespace": "", - "Type or select a namespace": "", - "Select existing or type to create a new namespace": "", - "No available namespaces - you can type a custom name": "", "Select an existing namespace to use as a project": "", "Create a new namespace and use it as a project": "", "Create a Project": "", diff --git a/frontend/src/i18n/locales/ja/translation.json b/frontend/src/i18n/locales/ja/translation.json index dedcc049b..3c2db266d 100644 --- a/frontend/src/i18n/locales/ja/translation.json +++ b/frontend/src/i18n/locales/ja/translation.json @@ -136,6 +136,10 @@ "Error deleting plugin {{ itemName }}.": "", "Uh-oh! Something went wrong.": "", "Error loading {{ routeName }}": "", + "Could not open cluster": "", + "Retry": "", + "Connecting to \"{{cluster}}\"": "", + "Preparing cluster…": "", "Cluster name must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character.": "", "Error": "", "Okay": "", @@ -283,6 +287,7 @@ "Failed to copy to clipboard": "", "Copy": "", "Open Issue on GitHub": "", + "Previous": "", "Scroll to bottom": "", "Container": "", "Lines": "", @@ -471,7 +476,6 @@ "Created": "", "Images": "", "Current": "", - "Previous": "", "Rolling back {{ itemName }} to previous version…": "", "Cancelled rollback of {{ itemName }}.": "", "Rolled back {{ itemName }} to previous version.": "", @@ -728,9 +732,6 @@ "Namespace Name": "", "A namespace with this name already exists on one or more selected clusters": "", "Enter a name for the new namespace": "", - "Type or select a namespace": "", - "Select existing or type to create a new namespace": "", - "No available namespaces - you can type a custom name": "", "Select an existing namespace to use as a project": "", "Create a new namespace and use it as a project": "", "Create a Project": "", diff --git a/frontend/src/i18n/locales/ko/translation.json b/frontend/src/i18n/locales/ko/translation.json index dedcc049b..3c2db266d 100644 --- a/frontend/src/i18n/locales/ko/translation.json +++ b/frontend/src/i18n/locales/ko/translation.json @@ -136,6 +136,10 @@ "Error deleting plugin {{ itemName }}.": "", "Uh-oh! Something went wrong.": "", "Error loading {{ routeName }}": "", + "Could not open cluster": "", + "Retry": "", + "Connecting to \"{{cluster}}\"": "", + "Preparing cluster…": "", "Cluster name must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character.": "", "Error": "", "Okay": "", @@ -283,6 +287,7 @@ "Failed to copy to clipboard": "", "Copy": "", "Open Issue on GitHub": "", + "Previous": "", "Scroll to bottom": "", "Container": "", "Lines": "", @@ -471,7 +476,6 @@ "Created": "", "Images": "", "Current": "", - "Previous": "", "Rolling back {{ itemName }} to previous version…": "", "Cancelled rollback of {{ itemName }}.": "", "Rolled back {{ itemName }} to previous version.": "", @@ -728,9 +732,6 @@ "Namespace Name": "", "A namespace with this name already exists on one or more selected clusters": "", "Enter a name for the new namespace": "", - "Type or select a namespace": "", - "Select existing or type to create a new namespace": "", - "No available namespaces - you can type a custom name": "", "Select an existing namespace to use as a project": "", "Create a new namespace and use it as a project": "", "Create a Project": "", diff --git a/frontend/src/i18n/locales/nl/translation.json b/frontend/src/i18n/locales/nl/translation.json index 0a04dc188..b6ba2611e 100644 --- a/frontend/src/i18n/locales/nl/translation.json +++ b/frontend/src/i18n/locales/nl/translation.json @@ -136,6 +136,10 @@ "Error deleting plugin {{ itemName }}.": "", "Uh-oh! Something went wrong.": "", "Error loading {{ routeName }}": "", + "Could not open cluster": "", + "Retry": "", + "Connecting to \"{{cluster}}\"": "", + "Preparing cluster…": "", "Cluster name must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character.": "", "Error": "", "Okay": "", @@ -284,6 +288,7 @@ "Failed to copy to clipboard": "", "Copy": "", "Open Issue on GitHub": "", + "Previous": "", "Scroll to bottom": "", "Container": "", "Lines": "", @@ -475,7 +480,6 @@ "Created": "", "Images": "", "Current": "", - "Previous": "", "Rolling back {{ itemName }} to previous version…": "", "Cancelled rollback of {{ itemName }}.": "", "Rolled back {{ itemName }} to previous version.": "", @@ -733,9 +737,6 @@ "Namespace Name": "", "A namespace with this name already exists on one or more selected clusters": "", "Enter a name for the new namespace": "", - "Type or select a namespace": "", - "Select existing or type to create a new namespace": "", - "No available namespaces - you can type a custom name": "", "Select an existing namespace to use as a project": "", "Create a new namespace and use it as a project": "", "Create a Project": "", diff --git a/frontend/src/i18n/locales/pl/translation.json b/frontend/src/i18n/locales/pl/translation.json index cb6d3f2ba..9f1da2f9e 100644 --- a/frontend/src/i18n/locales/pl/translation.json +++ b/frontend/src/i18n/locales/pl/translation.json @@ -136,6 +136,10 @@ "Error deleting plugin {{ itemName }}.": "", "Uh-oh! Something went wrong.": "", "Error loading {{ routeName }}": "", + "Could not open cluster": "", + "Retry": "", + "Connecting to \"{{cluster}}\"": "", + "Preparing cluster…": "", "Cluster name must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character.": "", "Error": "", "Okay": "", @@ -286,6 +290,7 @@ "Failed to copy to clipboard": "", "Copy": "", "Open Issue on GitHub": "", + "Previous": "", "Scroll to bottom": "", "Container": "", "Lines": "", @@ -483,7 +488,6 @@ "Created": "", "Images": "", "Current": "", - "Previous": "", "Rolling back {{ itemName }} to previous version…": "", "Cancelled rollback of {{ itemName }}.": "", "Rolled back {{ itemName }} to previous version.": "", @@ -743,9 +747,6 @@ "Namespace Name": "", "A namespace with this name already exists on one or more selected clusters": "", "Enter a name for the new namespace": "", - "Type or select a namespace": "", - "Select existing or type to create a new namespace": "", - "No available namespaces - you can type a custom name": "", "Select an existing namespace to use as a project": "", "Create a new namespace and use it as a project": "", "Create a Project": "", diff --git a/frontend/src/i18n/locales/pt-BR/translation.json b/frontend/src/i18n/locales/pt-BR/translation.json index ff3468578..45895be37 100644 --- a/frontend/src/i18n/locales/pt-BR/translation.json +++ b/frontend/src/i18n/locales/pt-BR/translation.json @@ -136,6 +136,10 @@ "Error deleting plugin {{ itemName }}.": "", "Uh-oh! Something went wrong.": "", "Error loading {{ routeName }}": "", + "Could not open cluster": "", + "Retry": "", + "Connecting to \"{{cluster}}\"": "", + "Preparing cluster…": "", "Cluster name must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character.": "", "Error": "", "Okay": "", @@ -285,6 +289,7 @@ "Failed to copy to clipboard": "", "Copy": "", "Open Issue on GitHub": "", + "Previous": "", "Scroll to bottom": "", "Container": "", "Lines": "", @@ -479,7 +484,6 @@ "Created": "", "Images": "", "Current": "", - "Previous": "", "Rolling back {{ itemName }} to previous version…": "", "Cancelled rollback of {{ itemName }}.": "", "Rolled back {{ itemName }} to previous version.": "", @@ -738,9 +742,6 @@ "Namespace Name": "", "A namespace with this name already exists on one or more selected clusters": "", "Enter a name for the new namespace": "", - "Type or select a namespace": "", - "Select existing or type to create a new namespace": "", - "No available namespaces - you can type a custom name": "", "Select an existing namespace to use as a project": "", "Create a new namespace and use it as a project": "", "Create a Project": "", diff --git a/frontend/src/i18n/locales/pt-PT/translation.json b/frontend/src/i18n/locales/pt-PT/translation.json index ff3468578..45895be37 100644 --- a/frontend/src/i18n/locales/pt-PT/translation.json +++ b/frontend/src/i18n/locales/pt-PT/translation.json @@ -136,6 +136,10 @@ "Error deleting plugin {{ itemName }}.": "", "Uh-oh! Something went wrong.": "", "Error loading {{ routeName }}": "", + "Could not open cluster": "", + "Retry": "", + "Connecting to \"{{cluster}}\"": "", + "Preparing cluster…": "", "Cluster name must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character.": "", "Error": "", "Okay": "", @@ -285,6 +289,7 @@ "Failed to copy to clipboard": "", "Copy": "", "Open Issue on GitHub": "", + "Previous": "", "Scroll to bottom": "", "Container": "", "Lines": "", @@ -479,7 +484,6 @@ "Created": "", "Images": "", "Current": "", - "Previous": "", "Rolling back {{ itemName }} to previous version…": "", "Cancelled rollback of {{ itemName }}.": "", "Rolled back {{ itemName }} to previous version.": "", @@ -738,9 +742,6 @@ "Namespace Name": "", "A namespace with this name already exists on one or more selected clusters": "", "Enter a name for the new namespace": "", - "Type or select a namespace": "", - "Select existing or type to create a new namespace": "", - "No available namespaces - you can type a custom name": "", "Select an existing namespace to use as a project": "", "Create a new namespace and use it as a project": "", "Create a Project": "", diff --git a/frontend/src/i18n/locales/ru/translation.json b/frontend/src/i18n/locales/ru/translation.json index cb6d3f2ba..9f1da2f9e 100644 --- a/frontend/src/i18n/locales/ru/translation.json +++ b/frontend/src/i18n/locales/ru/translation.json @@ -136,6 +136,10 @@ "Error deleting plugin {{ itemName }}.": "", "Uh-oh! Something went wrong.": "", "Error loading {{ routeName }}": "", + "Could not open cluster": "", + "Retry": "", + "Connecting to \"{{cluster}}\"": "", + "Preparing cluster…": "", "Cluster name must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character.": "", "Error": "", "Okay": "", @@ -286,6 +290,7 @@ "Failed to copy to clipboard": "", "Copy": "", "Open Issue on GitHub": "", + "Previous": "", "Scroll to bottom": "", "Container": "", "Lines": "", @@ -483,7 +488,6 @@ "Created": "", "Images": "", "Current": "", - "Previous": "", "Rolling back {{ itemName }} to previous version…": "", "Cancelled rollback of {{ itemName }}.": "", "Rolled back {{ itemName }} to previous version.": "", @@ -743,9 +747,6 @@ "Namespace Name": "", "A namespace with this name already exists on one or more selected clusters": "", "Enter a name for the new namespace": "", - "Type or select a namespace": "", - "Select existing or type to create a new namespace": "", - "No available namespaces - you can type a custom name": "", "Select an existing namespace to use as a project": "", "Create a new namespace and use it as a project": "", "Create a Project": "", diff --git a/frontend/src/i18n/locales/sv/translation.json b/frontend/src/i18n/locales/sv/translation.json index 0a04dc188..b6ba2611e 100644 --- a/frontend/src/i18n/locales/sv/translation.json +++ b/frontend/src/i18n/locales/sv/translation.json @@ -136,6 +136,10 @@ "Error deleting plugin {{ itemName }}.": "", "Uh-oh! Something went wrong.": "", "Error loading {{ routeName }}": "", + "Could not open cluster": "", + "Retry": "", + "Connecting to \"{{cluster}}\"": "", + "Preparing cluster…": "", "Cluster name must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character.": "", "Error": "", "Okay": "", @@ -284,6 +288,7 @@ "Failed to copy to clipboard": "", "Copy": "", "Open Issue on GitHub": "", + "Previous": "", "Scroll to bottom": "", "Container": "", "Lines": "", @@ -475,7 +480,6 @@ "Created": "", "Images": "", "Current": "", - "Previous": "", "Rolling back {{ itemName }} to previous version…": "", "Cancelled rollback of {{ itemName }}.": "", "Rolled back {{ itemName }} to previous version.": "", @@ -733,9 +737,6 @@ "Namespace Name": "", "A namespace with this name already exists on one or more selected clusters": "", "Enter a name for the new namespace": "", - "Type or select a namespace": "", - "Select existing or type to create a new namespace": "", - "No available namespaces - you can type a custom name": "", "Select an existing namespace to use as a project": "", "Create a new namespace and use it as a project": "", "Create a Project": "", diff --git a/frontend/src/i18n/locales/ta/translation.json b/frontend/src/i18n/locales/ta/translation.json index 0a04dc188..b6ba2611e 100644 --- a/frontend/src/i18n/locales/ta/translation.json +++ b/frontend/src/i18n/locales/ta/translation.json @@ -136,6 +136,10 @@ "Error deleting plugin {{ itemName }}.": "", "Uh-oh! Something went wrong.": "", "Error loading {{ routeName }}": "", + "Could not open cluster": "", + "Retry": "", + "Connecting to \"{{cluster}}\"": "", + "Preparing cluster…": "", "Cluster name must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character.": "", "Error": "", "Okay": "", @@ -284,6 +288,7 @@ "Failed to copy to clipboard": "", "Copy": "", "Open Issue on GitHub": "", + "Previous": "", "Scroll to bottom": "", "Container": "", "Lines": "", @@ -475,7 +480,6 @@ "Created": "", "Images": "", "Current": "", - "Previous": "", "Rolling back {{ itemName }} to previous version…": "", "Cancelled rollback of {{ itemName }}.": "", "Rolled back {{ itemName }} to previous version.": "", @@ -733,9 +737,6 @@ "Namespace Name": "", "A namespace with this name already exists on one or more selected clusters": "", "Enter a name for the new namespace": "", - "Type or select a namespace": "", - "Select existing or type to create a new namespace": "", - "No available namespaces - you can type a custom name": "", "Select an existing namespace to use as a project": "", "Create a new namespace and use it as a project": "", "Create a Project": "", diff --git a/frontend/src/i18n/locales/tr/translation.json b/frontend/src/i18n/locales/tr/translation.json index 0a04dc188..b6ba2611e 100644 --- a/frontend/src/i18n/locales/tr/translation.json +++ b/frontend/src/i18n/locales/tr/translation.json @@ -136,6 +136,10 @@ "Error deleting plugin {{ itemName }}.": "", "Uh-oh! Something went wrong.": "", "Error loading {{ routeName }}": "", + "Could not open cluster": "", + "Retry": "", + "Connecting to \"{{cluster}}\"": "", + "Preparing cluster…": "", "Cluster name must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character.": "", "Error": "", "Okay": "", @@ -284,6 +288,7 @@ "Failed to copy to clipboard": "", "Copy": "", "Open Issue on GitHub": "", + "Previous": "", "Scroll to bottom": "", "Container": "", "Lines": "", @@ -475,7 +480,6 @@ "Created": "", "Images": "", "Current": "", - "Previous": "", "Rolling back {{ itemName }} to previous version…": "", "Cancelled rollback of {{ itemName }}.": "", "Rolled back {{ itemName }} to previous version.": "", @@ -733,9 +737,6 @@ "Namespace Name": "", "A namespace with this name already exists on one or more selected clusters": "", "Enter a name for the new namespace": "", - "Type or select a namespace": "", - "Select existing or type to create a new namespace": "", - "No available namespaces - you can type a custom name": "", "Select an existing namespace to use as a project": "", "Create a new namespace and use it as a project": "", "Create a Project": "", diff --git a/frontend/src/i18n/locales/ur/translation.json b/frontend/src/i18n/locales/ur/translation.json index 2d08656d4..42b8715f3 100644 --- a/frontend/src/i18n/locales/ur/translation.json +++ b/frontend/src/i18n/locales/ur/translation.json @@ -136,6 +136,10 @@ "Error deleting plugin {{ itemName }}.": "پلگ ان {{ itemName }} حذف کرنے میں خرابی", "Uh-oh! Something went wrong.": "اوہ! کچھ غلط ہو گیا", "Error loading {{ routeName }}": "{{ routeName }} لوڈ کرنے میں خرابی", + "Could not open cluster": "", + "Retry": "", + "Connecting to \"{{cluster}}\"": "", + "Preparing cluster…": "", "Cluster name must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character.": "کلسٹر کے نام میں صرف چھوٹے حروف، اعداد یا '-' شامل ہو سکتے ہیں اور نام کا آغاز اور اختتام حرف یا عدد سے ہونا چاہیے", "Error": "خرابی", "Okay": "ٹھیک ہے", @@ -284,12 +288,14 @@ "Failed to copy to clipboard": "کلپ بورڈ میں کاپی نہیں ہو سکا", "Copy": "کاپی کریں", "Open Issue on GitHub": "GitHub پر مسئلہ کھولیں", + "Previous": "پچھلا", "Scroll to bottom": "", "Container": "کنٹینر", "Lines": "لائنیں", "Severity": "شدت", "Timestamps": "ٹائم اسٹیمپس", "Wrap lines": "", + "Log Level": "", "All levels": "", "Find": "تلاش کریں", "Download": "ڈاؤن لوڈ کریں", @@ -474,7 +480,6 @@ "Created": "بنایا گیا", "Images": "امیجز", "Current": "موجودہ", - "Previous": "پچھلا", "Rolling back {{ itemName }} to previous version…": "{{ itemName }} کو پچھلے ورژن پر واپس کیا جا رہا ہے…", "Cancelled rollback of {{ itemName }}.": "{{ itemName }} کا رول بیک منسوخ کر دیا گیا", "Rolled back {{ itemName }} to previous version.": "{{ itemName }} کو پچھلے ورژن پر واپس کر دیا گیا", @@ -732,9 +737,6 @@ "Namespace Name": "", "A namespace with this name already exists on one or more selected clusters": "", "Enter a name for the new namespace": "", - "Type or select a namespace": "نیم اسپیس لکھیں یا منتخب کریں", - "Select existing or type to create a new namespace": "موجودہ منتخب کریں یا نیا نیم اسپیس بنانے کے لیے لکھیں", - "No available namespaces - you can type a custom name": "کوئی دستیاب نیم اسپیس نہیں - آپ حسبِ ضرورت نام لکھ سکتے ہیں", "Select an existing namespace to use as a project": "", "Create a new namespace and use it as a project": "", "Create a Project": "پروجیکٹ بنائیں", diff --git a/frontend/src/i18n/locales/zh-Hans/translation.json b/frontend/src/i18n/locales/zh-Hans/translation.json index dedcc049b..3c2db266d 100644 --- a/frontend/src/i18n/locales/zh-Hans/translation.json +++ b/frontend/src/i18n/locales/zh-Hans/translation.json @@ -136,6 +136,10 @@ "Error deleting plugin {{ itemName }}.": "", "Uh-oh! Something went wrong.": "", "Error loading {{ routeName }}": "", + "Could not open cluster": "", + "Retry": "", + "Connecting to \"{{cluster}}\"": "", + "Preparing cluster…": "", "Cluster name must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character.": "", "Error": "", "Okay": "", @@ -283,6 +287,7 @@ "Failed to copy to clipboard": "", "Copy": "", "Open Issue on GitHub": "", + "Previous": "", "Scroll to bottom": "", "Container": "", "Lines": "", @@ -471,7 +476,6 @@ "Created": "", "Images": "", "Current": "", - "Previous": "", "Rolling back {{ itemName }} to previous version…": "", "Cancelled rollback of {{ itemName }}.": "", "Rolled back {{ itemName }} to previous version.": "", @@ -728,9 +732,6 @@ "Namespace Name": "", "A namespace with this name already exists on one or more selected clusters": "", "Enter a name for the new namespace": "", - "Type or select a namespace": "", - "Select existing or type to create a new namespace": "", - "No available namespaces - you can type a custom name": "", "Select an existing namespace to use as a project": "", "Create a new namespace and use it as a project": "", "Create a Project": "", diff --git a/frontend/src/i18n/locales/zh-Hant/translation.json b/frontend/src/i18n/locales/zh-Hant/translation.json index dedcc049b..3c2db266d 100644 --- a/frontend/src/i18n/locales/zh-Hant/translation.json +++ b/frontend/src/i18n/locales/zh-Hant/translation.json @@ -136,6 +136,10 @@ "Error deleting plugin {{ itemName }}.": "", "Uh-oh! Something went wrong.": "", "Error loading {{ routeName }}": "", + "Could not open cluster": "", + "Retry": "", + "Connecting to \"{{cluster}}\"": "", + "Preparing cluster…": "", "Cluster name must contain only lowercase alphanumeric characters or '-', and must start and end with an alphanumeric character.": "", "Error": "", "Okay": "", @@ -283,6 +287,7 @@ "Failed to copy to clipboard": "", "Copy": "", "Open Issue on GitHub": "", + "Previous": "", "Scroll to bottom": "", "Container": "", "Lines": "", @@ -471,7 +476,6 @@ "Created": "", "Images": "", "Current": "", - "Previous": "", "Rolling back {{ itemName }} to previous version…": "", "Cancelled rollback of {{ itemName }}.": "", "Rolled back {{ itemName }} to previous version.": "", @@ -728,9 +732,6 @@ "Namespace Name": "", "A namespace with this name already exists on one or more selected clusters": "", "Enter a name for the new namespace": "", - "Type or select a namespace": "", - "Select existing or type to create a new namespace": "", - "No available namespaces - you can type a custom name": "", "Select an existing namespace to use as a project": "", "Create a new namespace and use it as a project": "", "Create a Project": "", From 00e219f72df7ca239ed5274059fc0c0757d45779 Mon Sep 17 00:00:00 2001 From: yolossn Date: Wed, 22 Jul 2026 19:18:24 +0530 Subject: [PATCH 5/5] upstreamable: add Storybook states for cluster preOpen UI Extract the connecting popup out of RouteSwitcher into a pure ClusterPreparingDialog so its states are storybook-able, and add stories for it and for the AlertNotification suppressed state. Signed-off-by: yolossn --- .../App/ClusterPreparingDialog.stories.tsx | 52 +++++++++++++ .../components/App/ClusterPreparingDialog.tsx | 58 ++++++++++++++ frontend/src/components/App/RouteSwitcher.tsx | 22 +----- ...ingDialog.DefaultMessage.stories.storyshot | 75 +++++++++++++++++++ ...alog.WithProgressMessage.stories.storyshot | 75 +++++++++++++++++++ .../common/AlertNotification.stories.tsx | 11 +++ ...tNotification.Suppressed.stories.storyshot | 12 +++ 7 files changed, 285 insertions(+), 20 deletions(-) create mode 100644 frontend/src/components/App/ClusterPreparingDialog.stories.tsx create mode 100644 frontend/src/components/App/ClusterPreparingDialog.tsx create mode 100644 frontend/src/components/App/__snapshots__/ClusterPreparingDialog.DefaultMessage.stories.storyshot create mode 100644 frontend/src/components/App/__snapshots__/ClusterPreparingDialog.WithProgressMessage.stories.storyshot create mode 100644 frontend/src/components/common/__snapshots__/AlertNotification.Suppressed.stories.storyshot diff --git a/frontend/src/components/App/ClusterPreparingDialog.stories.tsx b/frontend/src/components/App/ClusterPreparingDialog.stories.tsx new file mode 100644 index 000000000..a8e07ce7e --- /dev/null +++ b/frontend/src/components/App/ClusterPreparingDialog.stories.tsx @@ -0,0 +1,52 @@ +/* + * Copyright 2025 The Kubernetes Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Meta, StoryFn } from '@storybook/react'; +import React from 'react'; +import { TestContext } from '../../test'; +import ClusterPreparingDialog, { ClusterPreparingDialogProps } from './ClusterPreparingDialog'; + +export default { + title: 'App/ClusterPreparingDialog', + component: ClusterPreparingDialog, + decorators: [ + Story => ( + + + + ), + ], + argTypes: { + cluster: { control: 'text' }, + message: { control: 'text' }, + }, +} satisfies Meta; + +const Template: StoryFn = args => ; + +export const WithProgressMessage = Template.bind({}); +WithProgressMessage.args = { + cluster: 'edge-cluster-1', + message: 'Starting AKS Hybrid & Edge proxy…', +}; +WithProgressMessage.storyName = 'With Progress Message'; + +export const DefaultMessage = Template.bind({}); +DefaultMessage.args = { + // No message reported yet — falls back to the generic "Preparing cluster…". + cluster: 'edge-cluster-1', +}; +DefaultMessage.storyName = 'Default (No Progress Message)'; diff --git a/frontend/src/components/App/ClusterPreparingDialog.tsx b/frontend/src/components/App/ClusterPreparingDialog.tsx new file mode 100644 index 000000000..2727c4fdc --- /dev/null +++ b/frontend/src/components/App/ClusterPreparingDialog.tsx @@ -0,0 +1,58 @@ +/* + * Copyright 2025 The Kubernetes Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Box from '@mui/material/Box'; +import CircularProgress from '@mui/material/CircularProgress'; +import Dialog from '@mui/material/Dialog'; +import DialogContent from '@mui/material/DialogContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import Typography from '@mui/material/Typography'; +import { useTranslation } from 'react-i18next'; + +export interface ClusterPreparingDialogProps { + /** Name of the cluster being connected to. */ + cluster: string; + /** + * Latest progress message reported by a pre-open hook (e.g. "Starting proxy…"). + * Falls back to a generic "Preparing cluster…" when a hook reports no message. + */ + message?: string; +} + +/** + * Modal "connecting" popup shown while a cluster's pre-open hooks run, so opening + * a cluster reads as a deliberate connect step rather than a bare page loader. + * + * Pure and prop-driven so its states are storybook-/test-able in isolation; + * `RouteSwitcher` renders it while preparation is pending. + */ +export default function ClusterPreparingDialog({ cluster, message }: ClusterPreparingDialogProps) { + const { t } = useTranslation(); + + return ( + + + {t('translation|Connecting to "{{cluster}}"', { cluster })} + + + + + + + ); +} diff --git a/frontend/src/components/App/RouteSwitcher.tsx b/frontend/src/components/App/RouteSwitcher.tsx index 4a6af3bdb..e5fb4f180 100644 --- a/frontend/src/components/App/RouteSwitcher.tsx +++ b/frontend/src/components/App/RouteSwitcher.tsx @@ -16,11 +16,6 @@ import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; -import CircularProgress from '@mui/material/CircularProgress'; -import Dialog from '@mui/material/Dialog'; -import DialogContent from '@mui/material/DialogContent'; -import DialogTitle from '@mui/material/DialogTitle'; -import Typography from '@mui/material/Typography'; import { useQuery } from '@tanstack/react-query'; import React, { Suspense } from 'react'; import { useTranslation } from 'react-i18next'; @@ -41,6 +36,7 @@ import { uiSlice } from '../../redux/uiSlice'; import ErrorBoundary from '../common/ErrorBoundary'; import ErrorComponent from '../common/ErrorPage'; import { useSidebarItem } from '../Sidebar'; +import ClusterPreparingDialog from './ClusterPreparingDialog'; export default function RouteSwitcher(props: { requiresToken: () => boolean }) { // The NotFoundRoute always has to be evaluated in the last place. @@ -295,21 +291,7 @@ export function AuthRoute(props: AuthRouteProps) { // the cluster reads as a deliberate connect step. Only the dialog renders // while preparation is pending; the cluster's views render once it // succeeds (this renderer returns `children` on success, below). - return ( - - - {t('translation|Connecting to "{{cluster}}"', { cluster })} - - - - - - - ); + return ; } } diff --git a/frontend/src/components/App/__snapshots__/ClusterPreparingDialog.DefaultMessage.stories.storyshot b/frontend/src/components/App/__snapshots__/ClusterPreparingDialog.DefaultMessage.stories.storyshot new file mode 100644 index 000000000..cd743b682 --- /dev/null +++ b/frontend/src/components/App/__snapshots__/ClusterPreparingDialog.DefaultMessage.stories.storyshot @@ -0,0 +1,75 @@ + +