This is an automated email from the ASF dual-hosted git repository.

mgubaidullin pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel-karavan.git

commit 45c2a0ad6cbabe18ec0f338cacc71fe9dbb91feb
Author: Marat Gubaidullin <[email protected]>
AuthorDate: Mon Aug 24 14:39:41 2026 -0400

    Karavan Webapp UI API
---
 karavan-app/src/main/webui/src/api/AccessApi.tsx   | 174 +++++
 .../src/main/webui/src/api/ComplexityApi.tsx       |  41 ++
 .../src/main/webui/src/api/DiagnosticsApi.tsx      |  22 +
 karavan-app/src/main/webui/src/api/KaravanApi.tsx  | 713 +++++++++++++++++++++
 .../src/main/webui/src/api/KubernetesApi.ts        |  20 +
 karavan-app/src/main/webui/src/api/LogWatchApi.tsx |  77 +++
 .../src/main/webui/src/api/NotificationApi.tsx     | 105 +++
 karavan-app/src/main/webui/src/api/SearchApi.tsx   |  26 +
 karavan-app/src/main/webui/src/api/SystemApi.tsx   | 194 ++++++
 .../src/main/webui/src/api/auth/AuthApi.tsx        | 253 ++++++++
 .../src/main/webui/src/api/auth/AuthFetch.ts       |  53 ++
 .../src/main/webui/src/api/auth/AuthProvider.tsx   | 103 +++
 karavan-app/src/main/webui/src/api/auth/SsoApi.tsx | 157 +++++
 13 files changed, 1938 insertions(+)

diff --git a/karavan-app/src/main/webui/src/api/AccessApi.tsx 
b/karavan-app/src/main/webui/src/api/AccessApi.tsx
new file mode 100644
index 00000000..e04e6cb3
--- /dev/null
+++ b/karavan-app/src/main/webui/src/api/AccessApi.tsx
@@ -0,0 +1,174 @@
+import {AccessPassword, AccessRole, AccessToken, AccessUser, 
GenerateTokenRequest, GenerateTokenResponse, SessionInfo} from 
"@models/AccessModels";
+import {ErrorEventBus} from "@bus/ErrorEventBus";
+import {AuthApi} from "./auth/AuthApi";
+import {AxiosResponse} from "axios";
+
+const instance = AuthApi.getInstance();
+
+export class AccessApi {
+
+    static async getUser(username: string): Promise<AccessUser | null> {
+        try {
+            const res = await instance.get(`/ui/access/users/${username}`);
+            return res.status === 200 ? res.data : null;
+        } catch (err) {
+            ErrorEventBus.sendApiError(err);
+            return null;
+        }
+    }
+
+    static async putUser(user: AccessUser): Promise<[boolean, AxiosResponse | 
any]> {
+        try {
+            const res = await instance.put('/ui/access/users', user);
+            return [res.status === 200, user];
+        } catch (err) {
+            ErrorEventBus.sendApiError(err);
+            return [false, err];
+        }
+    }
+
+    static async getUsers(): Promise<AccessUser[]> {
+        try {
+            const res = await instance.get('/ui/access/users');
+            return res.status === 200 ? res.data : [];
+        } catch (err) {
+            ErrorEventBus.sendApiError(err);
+            return [];
+        }
+    }
+
+    static async getRoles(): Promise<AccessRole[]> {
+        try {
+            const res = await instance.get('/ui/access/roles');
+            return res.status === 200 ? res.data : [];
+        } catch (err) {
+            ErrorEventBus.sendApiError(err);
+            return [];
+        }
+    }
+
+    static async getSessions(): Promise<SessionInfo[]> {
+        try {
+            const res = await instance.get('/ui/access/sessions');
+            return res.status === 200 ? res.data : [];
+        } catch (err) {
+            ErrorEventBus.sendApiError(err);
+            return [];
+        }
+    }
+
+    static async getTokens(): Promise<AccessToken[]> {
+        try {
+            const res = await instance.get('/ui/access/tokens');
+            return res.status === 200 ? res.data : [];
+        } catch (err) {
+            ErrorEventBus.sendApiError(err);
+            return [];
+        }
+    }
+
+    static async postUser(user: AccessUser): Promise<[boolean, AxiosResponse | 
any]> {
+        try {
+            const res = await instance.post('/ui/access/users', user);
+            return [res.status === 200 || res.status === 201, user];
+        } catch (err) {
+            ErrorEventBus.sendApiError(err);
+            return [false, err];
+        }
+    }
+
+    static async deleteUser(username: string): Promise<boolean> {
+        try {
+            const res = await instance.delete(`/ui/access/users/${username}`);
+            return res.status === 202;
+        } catch (err) {
+            ErrorEventBus.sendApiError(err);
+            return false;
+        }
+    }
+
+    static async deleteRole(rolename: string): Promise<boolean> {
+        try {
+            const res = await instance.delete(`/ui/access/roles/${rolename}`);
+            return res.status === 202;
+        } catch (err) {
+            ErrorEventBus.sendApiError(err);
+            return false;
+        }
+    }
+
+    static async deleteSession(username: string): Promise<boolean> {
+        try {
+            const res = await 
instance.delete(`/ui/access/sessions/${username}`);
+            return res.status === 202;
+        } catch (err) {
+            ErrorEventBus.sendApiError(err);
+            return false;
+        }
+    }
+
+    static async deleteToken(hashedToken: string): Promise<boolean> {
+        try {
+            const res = await 
instance.delete(`/ui/access/tokens/${hashedToken}`);
+            return res.status === 202;
+        } catch (err) {
+            ErrorEventBus.sendApiError(err);
+            return false;
+        }
+    }
+
+    static async setUserStatus(user: AccessUser, status: string): 
Promise<AccessUser | null> {
+        try {
+            const res = await instance.put(`/ui/access/users/${status}`, user);
+            return res.status === 200 ? res.data : null;
+        } catch (err) {
+            ErrorEventBus.sendApiError(err);
+            return null;
+        }
+    }
+
+    static async setUserRole(
+        user: AccessUser,
+        role: string | undefined,
+        command: "activate" | "inactivate" | "add" | "remove"
+    ): Promise<AccessUser | null> {
+        try {
+            const res = await instance.put('/ui/access/userRole', { username: 
user.username, role, command });
+            return res.status === 200 ? res.data : null;
+        } catch (err) {
+            ErrorEventBus.sendApiError(err);
+            return null;
+        }
+    }
+
+    static async postRole(role: AccessRole): Promise<[boolean, AxiosResponse | 
any]> {
+        try {
+            const res = await instance.post('/ui/access/roles', role);
+            return [res.status === 200 || res.status === 201, role];
+        } catch (err) {
+            ErrorEventBus.sendApiError(err);
+            return [false, err];
+        }
+    }
+
+    static async generateToken(request: GenerateTokenRequest): 
Promise<[boolean, GenerateTokenResponse | any]> {
+        try {
+            const res = await instance.post('/ui/access/tokens', request);
+            // On success, res.data contains the GenerateTokenResponse 
(rawToken + metadata)
+            return [res.status === 200 || res.status === 201, res.data];
+        } catch (err) {
+            ErrorEventBus.sendApiError(err);
+            return [false, err];
+        }
+    }
+
+    static async setPassword(username: string, password: AccessPassword): 
Promise<[boolean, AxiosResponse | any]> {
+        try {
+            const res = await instance.post('/ui/access/password', { 
...password, username });
+            return [res.status === 200 || res.status === 201 || res.status === 
204, res];
+        } catch (err) {
+            ErrorEventBus.sendApiError(err);
+            return [false, err];
+        }
+    }
+}
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/api/ComplexityApi.tsx 
b/karavan-app/src/main/webui/src/api/ComplexityApi.tsx
new file mode 100644
index 00000000..1ae0ad92
--- /dev/null
+++ b/karavan-app/src/main/webui/src/api/ComplexityApi.tsx
@@ -0,0 +1,41 @@
+import axios from "axios";
+import {ErrorEventBus} from "@bus/ErrorEventBus";
+import {ComplexityProject} from "@models/ComplexityModels";
+import {AuthApi} from "./auth/AuthApi";
+
+axios.defaults.headers.common['Accept'] = 'application/json';
+axios.defaults.headers.common['Content-Type'] = 'application/json';
+const instance = AuthApi.getInstance();
+
+export class ComplexityApi {
+
+    static async getComplexityProject(projectId: string, after: (complexity?: 
ComplexityProject) => void) {
+        instance.get('/ui/complexity/' + projectId)
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                } else {
+                    after(undefined);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+            after(undefined);
+        });
+    }
+
+    static async getComplexityProjects(after: (complexities: 
ComplexityProject[]) => void) {
+        const x = Date.now();
+        instance.get('/ui/complexity')
+            .then(res => {
+                if (res.status === 200) {
+                    const c: ComplexityProject[] = Array.isArray(res.data) ? 
res.data?.map(x => new ComplexityProject(x)) : [];
+                    after(c);
+                } else {
+                    after([]);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+            after([]);
+        });
+    }
+}
diff --git a/karavan-app/src/main/webui/src/api/DiagnosticsApi.tsx 
b/karavan-app/src/main/webui/src/api/DiagnosticsApi.tsx
new file mode 100644
index 00000000..79ef89fd
--- /dev/null
+++ b/karavan-app/src/main/webui/src/api/DiagnosticsApi.tsx
@@ -0,0 +1,22 @@
+import axios from "axios";
+import {AuthApi} from "./auth/AuthApi";
+import {ErrorEventBus} from "@bus/ErrorEventBus";
+import {CamelStatus} from "@models/ProjectModels";
+
+axios.defaults.headers.common['Accept'] = 'application/json';
+axios.defaults.headers.common['Content-Type'] = 'application/json';
+const instance = AuthApi.getInstance();
+
+export class DiagnosticsApi {
+
+    static async getAllCamelStatuses(after: (statuses: CamelStatus[]) => void) 
{
+        instance.get('/ui/status/camel')
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+}
diff --git a/karavan-app/src/main/webui/src/api/KaravanApi.tsx 
b/karavan-app/src/main/webui/src/api/KaravanApi.tsx
new file mode 100644
index 00000000..f99d91c5
--- /dev/null
+++ b/karavan-app/src/main/webui/src/api/KaravanApi.tsx
@@ -0,0 +1,713 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 axios, {AxiosResponse} from "axios";
+import {
+    AppConfig,
+    CamelStatus,
+    CamelStatusName,
+    ContainerStatus,
+    DeploymentStatus,
+    Project,
+    ProjectCommited,
+    ProjectFile,
+    ProjectFileCommited,
+    ProjectType,
+    ServiceStatus
+} from "@models/ProjectModels";
+import {Buffer} from 'buffer';
+import {EventBus} from "@designer/utils/EventBus";
+import {ErrorEventBus} from "@bus/ErrorEventBus";
+import {AuthApi, getCurrentUser} from "./auth/AuthApi";
+import {ProjectFolderCommit} from "@stores/CommitsStore";
+
+const instance = AuthApi.getInstance();
+
+export class KaravanApi {
+
+    static async getReadiness(after: (readiness: any) => void): Promise<void> {
+        axios.get('/public/readiness', {headers: {'Accept': 
'application/json'}})
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                } else {
+                    after(undefined);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+    
+    static async getConfiguration(after: (config: AppConfig) => void) {
+        instance.get('/ui/configuration')
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    static async getInfrastructureInfo(after: (info: any) => void) {
+        instance.get('/ui/configuration/info')
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    static async getProject(projectId: string, after: (project: Project) => 
void) {
+        instance.get('/ui/project/' + projectId)
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    static async getAllCamelStatuses(name: CamelStatusName | null, after: 
(statuses: CamelStatus[]) => void) {
+        instance.get(`/ui/status/camel/${name || ''}`)
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    static async getProjects(after: (projects: Project[]) => void, type?: 
ProjectType.integration) {
+        instance.get('/ui/project' + (type !== undefined ? "?type=" + type : 
""))
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data.map((p: Partial<Project> | undefined) => 
new Project(p)));
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+    static async getProjectsCommited(after: (projects: ProjectCommited[]) => 
void) {
+        instance.get('/ui/project/commited/all')
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    static async postProject(project: Project, after: (result: boolean, res: 
AxiosResponse<Project> | any) => void) {
+        try {
+            instance.post('/ui/project', project)
+                .then(res => {
+                    if (res.status === 200) {
+                        after(true, res);
+                    }
+                }).catch(err => {
+                console.error(err);
+                after(false, err);
+                EventBus.sendAlert("Error", err?.message, "danger")
+            });
+        } catch (error: any) {
+            console.error(error);
+            after(false, error);
+            EventBus.sendAlert("Error", error?.message, "danger")
+        }
+    }
+
+    static copyProject(sourceProject: string, project: Project, after: 
(result: boolean, res: AxiosResponse<Project> | any) => void) {
+        try {
+            instance.post('/ui/project/copy/' + sourceProject, project)
+                .then(res => {
+                    if (res.status === 200) {
+                        after(true, res);
+                    }
+                }).catch(err => {
+                after(false, err);
+            });
+        } catch (error: any) {
+            after(false, error);
+        }
+    }
+
+    static async deleteProject(project: Project, deleteContainers: boolean, 
after: (res: AxiosResponse<any>) => void) {
+        instance.delete('/ui/project/' + encodeURI(project.projectId) + 
(deleteContainers ? '?deleteContainers=true' : ''))
+            .then(res => {
+                after(res);
+            }).catch(err => {
+            after(err);
+        });
+    }
+
+    static async buildProject(project: Project, tag: string, after: (res: 
AxiosResponse<any>) => void) {
+        instance.post('/ui/project/build/' + tag, project)
+            .then(res => {
+                after(res);
+            }).catch(err => {
+            after(err);
+        });
+    }
+
+    static async getFiles(projectId: string, after: (files: ProjectFile[]) => 
void) {
+        instance.get(`/ui/file/${projectId}`)
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+    static async getCommitedFiles(projectId: string, after: (files: 
ProjectFileCommited[]) => void) {
+        instance.get(`/ui/file/commited/${projectId}`)
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    static async getProjectCommits(projectId: string, after: (commits: 
ProjectFolderCommit[]) => void) {
+        instance.get(`/ui/git/commits/${projectId}`)
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    static async loadProjectCommits(projectId: string, after: (res: any) => 
void) {
+        instance.post(`/ui/git/commits/${projectId}`)
+            .then(res => {
+                if (res.status === 202) {
+                    after(res);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    static async getSystemCommits(after: (res: any) => void) {
+        instance.get(`/ui/git/system`)
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    static async getProjectFilesByName(projectId: string, filename: string, 
after: (files: ProjectFile) => void) {
+        instance.get(`/ui/file/${projectId}?filename=${filename}`)
+            .then(res => {
+                if (res.status === 200 && res.data !== undefined) {
+                    after(res.data?.at(0));
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    static async getFilesByName(filename: string, after: (files: 
ProjectFile[]) => void) {
+        instance.get(`/ui/file?filename=${filename}`)
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    static async getFileCommited(projectId: string, filename: string, after: 
(file: ProjectFile) => void) {
+        instance.get('/ui/file/commited/' + projectId + '/' + filename)
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    static async getFilesDiff(projectId: string, after: (diff: any) => void) {
+        instance.get('/ui/file/diff/' + projectId)
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    static async saveProjectFile(file: ProjectFile, after: (result: boolean, 
file: ProjectFile | any) => void) {
+        try {
+            instance.post('/ui/file', file)
+                .then(res => {
+                    if (res.status === 200) {
+                        after(true, res.data);
+                    } else {
+                        after(false, res?.data);
+                    }
+                }).catch(err => {
+                    after(false, err);
+            });
+        } catch (error: any) {
+            after(false, error);
+        }
+    }
+
+    static async renameProjectFile(projectId: string, filename: string, 
newName: string, after: (result: boolean, err?: Error) => void) {
+        try {
+            instance.patch(`/ui/file/${projectId}/${filename}`, {newName: 
newName})
+                .then(res => {
+                    if (res.status === 200) {
+                        after(true);
+                    } else if (res.status === 409) {
+                        after(false, {message: res?.data} as Error);
+                    } else {
+                        after(false);
+                    }
+                }).catch(err => {
+                after(false, err);
+            });
+        } catch (error: any) {
+            after(false, error);
+        }
+    }
+
+    static async putProjectFile(file: ProjectFile, after: (res: 
AxiosResponse<any>) => void) {
+        instance.put('/ui/file', file)
+            .then(res => {
+                after(res);
+            }).catch(err => {
+            after(err);
+        });
+    }
+
+    static async deleteProjectFile(file: ProjectFile, after: (res: 
AxiosResponse<any>) => void) {
+        instance.delete('/ui/file/' + file.projectId + '/' + file.name)
+            .then(res => {
+                after(res);
+            }).catch(err => {
+            after(err);
+        });
+    }
+
+    static async deleteProjectFileByName(projectId: string, filename: string, 
after: (res: AxiosResponse<any>) => void) {
+        instance.delete(`/ui/file/${projectId}/${filename}`)
+            .then(res => {
+                after(res);
+            }).catch(err => {
+            after(err);
+        });
+    }
+
+    static async copyProjectFile(fromProjectId: string, fromFilename: string, 
toProjectId: string, toFilename: string, overwrite: boolean, after: (res: 
AxiosResponse<any>) => void) {
+        instance.post('/ui/file/copy', {fromProjectId: fromProjectId, 
fromFilename: fromFilename, toProjectId: toProjectId, toFilename: toFilename, 
overwrite: overwrite})
+            .then(res => {
+                after(res);
+            }).catch(err => {
+            after(err);
+        });
+    }
+
+    static async push(params: {}, after: (res: AxiosResponse<any>) => void) {
+        instance.post('/ui/git', params)
+            .then(res => {
+                after(res);
+            }).catch(err => {
+            after(err);
+        });
+    }
+
+    static async pull(projectId: string | undefined, after: (res: 
AxiosResponse<any> | any) => void) {
+        instance.put(`/ui/git/${projectId ?? ''}`)
+            .then(res => {
+                after(res);
+            }).catch(err => {
+            after(err);
+        });
+    }
+
+
+    static async reloadDevModeCode(projectId: string, after: (res: 
AxiosResponse<any>) => void) {
+        instance.get('/ui/devmode/reload/' + projectId)
+            .then(res => {
+                after(res);
+            }).catch(err => {
+            after(err);
+        });
+    }
+
+    static async getProjectCamelStatuses(projectId: string, after: (res: 
AxiosResponse<CamelStatus[]>) => void) {
+        instance.get(`/ui/project/status/camel/${projectId}` )
+            .then(res => {
+                after(res);
+            }).catch(err => {
+            after(err);
+        });
+    }
+
+    static async getProjectCamelTraces(projectId: string, after: (res: 
AxiosResponse<CamelStatus[]>) => void) {
+        instance.get(`/ui/project/traces/${projectId}`)
+            .then(res => {
+                after(res);
+            }).catch(err => {
+            after(err);
+        });
+    }
+
+    static async startDevModeContainer(projectId: string, verbose: boolean, 
compile: boolean, after: (res: AxiosResponse<any>) => void) {
+        
instance.get(`/ui/devmode/run/${projectId}/${verbose.toString()}/${compile.toString()}`)
+            .then(res => {
+                after(res);
+            }).catch(err => {
+            after(err);
+        });
+    }
+
+    static async deleteDevModeContainer(name: string, deletePVC: boolean, 
after: (res: AxiosResponse<any>) => void) {
+        instance.delete('/ui/devmode/' + name + "/" + deletePVC)
+            .then(res => {
+                after(res);
+            }).catch(err => {
+            after(err);
+        });
+    }
+
+
+
+    static async stopBuild(environment: string, buildName: string, after: 
(res: AxiosResponse<any>) => void) {
+        instance.delete('/ui/project/build/' + environment + "/" + buildName)
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    static async getContainerLog(environment: string, name: string, after: 
(res: AxiosResponse<string>) => void) {
+        instance.get('/ui/container/log/' + environment + "/" + name)
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    static async getAllServiceStatuses(after: (statuses: ServiceStatus[]) => 
void) {
+        instance.get('/ui/infrastructure/service')
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    static async getAllContainerStatuses(after: (statuses: ContainerStatus[]) 
=> void) {
+        instance.get('/ui/container')
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    static async getContainerStatus(projectId: string, after: (res: 
AxiosResponse<ContainerStatus[]>) => void) {
+        instance.get(`/ui/container/${projectId}`)
+            .then(res => {
+                after(res);
+            }).catch(err => {
+            after(err);
+        });
+    }
+
+    static async getAllDeploymentStatuses(): Promise<DeploymentStatus[]> {
+        try {
+            const res = await instance.get('/ui/infrastructure/deployment');
+            return res.status === 200 ? res.data : [];
+        } catch (err: any) {
+            ErrorEventBus.sendApiError(err);
+            EventBus.sendAlert("Error", err?.message, "danger");
+            return [];
+        }
+    }
+
+    static async rolloutDeployment(projectId: string, environment: string, 
after: (res: AxiosResponse<any>) => void) {
+        instance.post('/ui/infrastructure/deployment/rollout/' + environment + 
'/' + projectId, "")
+            .then(res => {
+                after(res);
+            }).catch(err => {
+            after(err);
+        });
+    }
+
+    static async startDeployment(projectId: string, environment: string, 
after: (res: AxiosResponse<any>) => void) {
+        instance.post('/ui/infrastructure/deployment/start/' + environment + 
'/' + projectId, "")
+            .then(res => {
+                after(res);
+            }).catch(err => {
+            after(err);
+        });
+    }
+
+    static async deleteDeployment(environment: string, name: string, after: 
(res: AxiosResponse<any>) => void) {
+        instance.delete('/ui/infrastructure/deployment/' + environment + '/' + 
name)
+            .then(res => {
+                after(res);
+            }).catch(err => {
+            after(err);
+        });
+    }
+
+    static async manageContainer(projectId: string,
+                                 type: 'devmode' | 'packaged' | 'internal' | 
'build' | 'unknown',
+                                 name: string,
+                                 command: 'deploy' | 'run' | 'pause' | 'stop' 
| 'delete',
+                                 pullImage: 'always' | 'ifNotExists' | 'never',
+                                 after: (res: AxiosResponse<any> | any) => 
void) {
+        instance.post('/ui/container/' + projectId + '/' + type + "/" + name, 
{command: command, pullImage: pullImage})
+            .then(res => {
+                after(res);
+            }).catch(err => {
+            after(err);
+        });
+    }
+
+    static async deleteContainer(projectId: string, type: 'devmode' | 
'packaged' | 'internal' | 'build' | 'unknown', name: string, after: (res: 
AxiosResponse<any>) => void) {
+        instance.delete('/ui/container/' + projectId + '/' + type + "/" + name)
+            .then(res => {
+                after(res);
+            }).catch(err => {
+            after(err);
+        });
+    }
+
+    static async getConfigMaps(after: (any: []) => void) {
+        instance.get('/ui/infrastructure/configmaps/')
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    static async getImages(projectId: string, after: (string: []) => void) {
+        instance.get('/ui/image/project/' + projectId)
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    static async setProjectImage(projectId: string, imageName: string, commit: 
boolean, message: string, after: (res: AxiosResponse<any>) => void) {
+        instance.post('/ui/image/project/' + projectId, {imageName: imageName, 
commit: commit, message: message})
+            .then(res => {
+                after(res);
+            }).catch(err => {
+            after(err);
+        });
+    }
+
+    static async deleteImage(imageName: string, after: () => void) {
+        instance.delete('/ui/image/project/' + 
Buffer.from(imageName).toString('base64'))
+            .then(res => {
+                if (res.status === 200) {
+                    after();
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    static async pullProjectImages(projectId: string, after: (res: 
AxiosResponse<any>) => void) {
+        const params = {
+            'projectId': projectId,
+            'userId': getCurrentUser()?.username
+        };
+        instance.post('/ui/image/pull/', params)
+            .then(res => {
+                after(res);
+            }).catch(err => {
+            after(err);
+        });
+    }
+
+    static async getSecrets(after: (any: []) => void) {
+        instance.get('/ui/infrastructure/secrets')
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    static async getServices(after: (any: []) => void) {
+        instance.get('/ui/infrastructure/services')
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    static async deleteAllStatuses(after: (res: AxiosResponse<any>) => void) {
+        instance.delete('/ui/status/all/')
+            .then(res => {
+                after(res);
+            }).catch(err => {
+            after(err);
+        });
+    }
+
+    static async restartInformers(after: (res: AxiosResponse<any>) => void) {
+        instance.put('/ui/infrastructure/informers/')
+            .then(res => {
+                after(res);
+            }).catch(err => {
+            after(err);
+        });
+    }
+
+    static async getCustomKamelets(after: (yaml: string) => void) {
+        instance.get('/ui/metadata/kamelets/kamelets', {headers: {'Accept': 
'text/plain'}, timeout: 0})
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    static async getComponents(after: (json: string) => void) {
+        instance.get('/ui/metadata/components', {timeout: 0})
+            .then(res => {
+                if (res.status === 200) {
+                    after(JSON.stringify(res.data));
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    static async getBeans(after: (json: string) => void) {
+        instance.get('/ui/metadata/beans', {timeout: 0})
+            .then(res => {
+                if (res.status === 200) {
+                    after(JSON.stringify(res.data));
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    static async getMetadataConfiguration(configName: string, after: (json: 
string) => void) {
+        instance.get(`/ui/metadata/${configName}Configuration`, { timeout: 0 })
+            .then(res => {
+                if (res.status === 200) {
+                    after(JSON.stringify(res.data));
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    static async getConfigurationChanges(after: (json: string) => void) {
+        instance.get('/ui/metadata/configurationChanges', {timeout: 0})
+            .then(res => {
+                if (res.status === 200) {
+                    after(JSON.stringify(res.data));
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    static async getProjectsActivities(after: (activities?: any) => void) {
+        instance.get('/ui/activity/projects')
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                } else {
+                    after(undefined);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+            after(undefined);
+        });
+    }
+
+    static async getUsersActivities(after: (activities?: any) => void) {
+        instance.get('/ui/activity/users')
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                } else {
+                    after(undefined);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+            after(undefined);
+        });
+    }
+
+    static async getProjectsLabels(after: (labels?: any) => void) {
+        instance.get('/ui/labels')
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                } else {
+                    after(undefined);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+            after(undefined);
+        });
+    }
+}
diff --git a/karavan-app/src/main/webui/src/api/KubernetesApi.ts 
b/karavan-app/src/main/webui/src/api/KubernetesApi.ts
new file mode 100644
index 00000000..1a1c2881
--- /dev/null
+++ b/karavan-app/src/main/webui/src/api/KubernetesApi.ts
@@ -0,0 +1,20 @@
+import {AuthApi} from "./auth/AuthApi";
+import {ErrorEventBus} from "@bus/ErrorEventBus";
+import {PodEvent} from "@models/ProjectModels";
+
+const instance = AuthApi.getInstance();
+
+export class KubernetesApi {
+
+    static async getPodEvents(containerName: string): Promise<PodEvent[]> {
+        try {
+            const res = await 
instance.get(`/ui/infrastructure/pod-events/${containerName}`, {
+                headers: { 'Accept': 'application/json' }
+            });
+            return res.status === 200 ? res.data : [];
+        } catch (err) {
+            ErrorEventBus.sendApiError(err);
+            return [];
+        }
+    }
+}
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/api/LogWatchApi.tsx 
b/karavan-app/src/main/webui/src/api/LogWatchApi.tsx
new file mode 100644
index 00000000..e4f9a6ba
--- /dev/null
+++ b/karavan-app/src/main/webui/src/api/LogWatchApi.tsx
@@ -0,0 +1,77 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 {fetchEventSource} from "@microsoft/fetch-event-source";
+import {LogsEventBus} from "@bus/LogsEventBus";
+import {getCurrentUser} from "./auth/AuthApi";
+import {authFetch, sseRetryInterval} from "./auth/AuthFetch";
+
+export class LogWatchApi {
+
+    static async fetchData(type: 'container' | 'build' | 'none', podName: 
string, controller: AbortController) {
+        console.log("Fetch Started for: " + podName);
+        const fetchData = async () => {
+            const headers: Record<string, string> = {
+                Accept: "text/event-stream",
+            };
+            const url = 
`/ui/logwatch/${type}/${podName}/${getCurrentUser()?.username ?? ""}`;
+            // The Authorization header is added by `authFetch` on every 
attempt so
+            // that reconnects use a freshly refreshed token instead of 
replaying
+            // the token captured when the stream was first opened.
+            let attempt = 0;
+            await fetchEventSource(url, {
+                method: "GET", headers: headers, signal: controller.signal, 
credentials: "include",
+                fetch: authFetch,
+                async onopen(response) {
+                    attempt = 0;
+                    const ct = response.headers.get("content-type") || "";
+                    if (response.ok && 
ct.toLowerCase().startsWith("text/event-stream")) {
+                        return; // good to go
+                    }
+                    // Handle auth and other errors explicitly
+                    if (response.status === 401) {
+                        console.warn("SSE unauthorized: session 
missing/expired.");
+                        // Optional: trigger a global event/router redirect 
here
+                        throw new Error("unauthorized");
+                    }
+                    console.error("Unexpected SSE response", response.status, 
ct);
+                    throw new Error(`bad-sse-response:${response.status}`);
+                },
+                onmessage(event) {
+                    if (event.event !== 'ping') {
+                        LogsEventBus.sendLog('add', event.data);
+                    } else {
+                        console.log('Logger SSE Ping', event);
+                    }
+                },
+                onclose() {
+                    console.log("Connection closed by the server");
+                },
+                onerror(err) {
+                    // Never retry an auth failure: the token was already 
refreshed
+                    // before this attempt, so retrying only floods the 
backend.
+                    if (err instanceof Error && err.message === 
"unauthorized") {
+                        throw err;
+                    }
+                    console.log("There was an error from server", err);
+                    return sseRetryInterval(attempt++);
+                },
+            });
+        };
+        return fetchData();
+    }
+}
diff --git a/karavan-app/src/main/webui/src/api/NotificationApi.tsx 
b/karavan-app/src/main/webui/src/api/NotificationApi.tsx
new file mode 100644
index 00000000..7ce5fa2f
--- /dev/null
+++ b/karavan-app/src/main/webui/src/api/NotificationApi.tsx
@@ -0,0 +1,105 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 {EventStreamContentType, fetchEventSource} from 
"@microsoft/fetch-event-source";
+import {EventSourceMessage} from "@microsoft/fetch-event-source/lib/cjs/parse";
+import {KaravanEvent, NotificationEventBus} from 
"@services/NotificationService";
+import {getCurrentUser} from "./auth/AuthApi";
+import {authFetch, sseRetryInterval} from "./auth/AuthFetch";
+
+export class NotificationApi {
+
+    static getKaravanEvent (ev: EventSourceMessage, type: 'system' | 'user') {
+        const eventParts = ev.event?.split(':');
+        const event = eventParts?.length > 1 ? eventParts[0] : undefined;
+        const className = eventParts?.length > 1 ? eventParts[1] : undefined;
+        return new KaravanEvent({id: ev.id, event: event, type: type, 
className: className, data: JSON.parse(ev.data)});
+    }
+
+    static onSystemMessage (ev: EventSourceMessage) {
+        const ke = NotificationApi.getKaravanEvent(ev, 'system');
+        NotificationEventBus.sendEvent(ke);
+    }
+
+    static onUserMessage (ev: EventSourceMessage) {
+        const ke = NotificationApi.getKaravanEvent(ev, 'user');
+        NotificationEventBus.sendEvent(ke);
+    }
+
+    static async notification(controller: AbortController) {
+        const fetchData = async () => {
+            // No Authorization header here on purpose: `authFetch` adds a 
freshly
+            // refreshed token per connection attempt, so reconnects never 
replay
+            // the token that was current when the stream was first opened.
+            const headers: any = { Accept: "text/event-stream" };
+            if (getCurrentUser()) {
+                NotificationApi.fetch('/ui/notification/system/' + 
getCurrentUser()?.username, controller, headers,
+                    ev => NotificationApi.onSystemMessage(ev));
+                NotificationApi.fetch('/ui/notification/user/' + 
getCurrentUser()?.username, controller, headers,
+                    ev => NotificationApi.onUserMessage(ev));
+            }
+        };
+        return fetchData();
+    };
+
+    static async fetch(input: string, controller: AbortController, headers: 
any, onmessage: (ev: EventSourceMessage) => void) {
+        let attempt = 0;
+        fetchEventSource(input, {
+            method: "GET",
+            headers: headers,
+            signal: controller.signal,
+            credentials: "include",
+            fetch: authFetch,
+            async onopen(response) {
+                if (response.ok && response.headers.get('content-type') === 
EventStreamContentType) {
+                    attempt = 0;
+                    return; // everything's good
+                } else if (response.status === 401) {
+                    console.warn("SSE unauthorized: session missing/expired.");
+                    // Optional: trigger a global event/router redirect here
+                    throw new Error("unauthorized");
+                } else if (response.status >= 400 && response.status < 500 && 
response.status !== 429) {
+                    // client-side errors are usually non-retriable:
+                    console.error("Server side error ", response);
+                    // EventBus.sendAlert("Error fetching", `${input} : 
${response.statusText}`, "danger");
+                } else {
+                    console.error("Error ", response);
+                    // EventBus.sendAlert("Error fetching", `${input} : 
${response.statusText}`, "danger");
+                }
+            },
+            onmessage(event) {
+                if (event.event !== 'ping') {
+                    onmessage(event);
+                } else {
+                    console.log('Notification SSE Ping', event);
+                }
+            },
+            onclose() {
+                console.log("Connection closed by the server");
+            },
+            onerror(err) {
+                // Never retry an auth failure: the token was already refreshed
+                // before this attempt, so retrying only floods the backend.
+                if (err instanceof Error && err.message === "unauthorized") {
+                    throw err;
+                }
+                console.log("There was an error from server", err);
+                return sseRetryInterval(attempt++);
+            },
+        });
+    }
+}
diff --git a/karavan-app/src/main/webui/src/api/SearchApi.tsx 
b/karavan-app/src/main/webui/src/api/SearchApi.tsx
new file mode 100644
index 00000000..cffd280b
--- /dev/null
+++ b/karavan-app/src/main/webui/src/api/SearchApi.tsx
@@ -0,0 +1,26 @@
+import axios from "axios";
+import {ErrorEventBus} from "@bus/ErrorEventBus";
+import {SearchResult} from "@models/SearchModels";
+import {AuthApi} from "./auth/AuthApi";
+
+axios.defaults.headers.common['Accept'] = 'application/json';
+axios.defaults.headers.common['Content-Type'] = 'application/json';
+const instance = AuthApi.getInstance();
+
+export class SearchApi {
+
+    static async searchAll(string: string, after: (result?: SearchResult[]) => 
void) {
+        const encoded = encodeURIComponent(string);
+        instance.get('/ui/search/all/' + encoded)
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                } else {
+                    after(undefined);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+            after(undefined);
+        });
+    }
+}
diff --git a/karavan-app/src/main/webui/src/api/SystemApi.tsx 
b/karavan-app/src/main/webui/src/api/SystemApi.tsx
new file mode 100644
index 00000000..4f325d93
--- /dev/null
+++ b/karavan-app/src/main/webui/src/api/SystemApi.tsx
@@ -0,0 +1,194 @@
+import axios from "axios";
+import {AuthApi} from "./auth/AuthApi";
+import {ErrorEventBus} from "@bus/ErrorEventBus";
+import {KubernetesConfigMap, KubernetesSecret} from "@models/SystemModels";
+import {Buffer} from 'buffer';
+
+axios.defaults.headers.common['Accept'] = 'application/json';
+axios.defaults.headers.common['Content-Type'] = 'application/json';
+const instance = AuthApi.getInstance();
+
+export class SystemApi {
+
+    // Secrets
+    static async createSecret(secretName: string, after: (val: string) => 
void) {
+        instance.post('/platform/system/secrets/' + 
Buffer.from(secretName).toString('base64'), {},{headers: {'Content-Type': 
'text/plain'}})
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    static async getSecrets(after: (secrets: KubernetesSecret[]) => void) {
+        instance.get('/platform/system/secrets', {headers: {'Accept': 
'application/json'}})
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    static async deleteSecret(secretName: string, after: (val: string) => 
void) {
+        instance.delete('/platform/system/secrets/' + 
Buffer.from(secretName).toString('base64'))
+            .then(res => {
+                if (res.status === 204) {
+                    after(res.data);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    static async getSecretValue(secretName: string, secretKey: string, after: 
(val: string) => void) {
+        instance.get('/platform/system/secrets/' + 
Buffer.from(secretName).toString('base64') + '/' + 
Buffer.from(secretKey).toString('base64'), {headers: {'Accept': 
'application/json'}})
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    static async setSecretValue(secretName: string, secretKey: string, value: 
string, after: (val: string) => void) {
+        instance.post('/platform/system/secrets/' + 
Buffer.from(secretName).toString('base64') + '/' + 
Buffer.from(secretKey).toString('base64'),
+            Buffer.from(value, 'binary').toString('base64'),
+            {headers: {'Content-Type': 'text/plain'}})
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    static async deleteSecretValue(secretName: string, secretKey: string, 
after: (val: string) => void) {
+        instance.delete('/platform/system/secrets/' + 
Buffer.from(secretName).toString('base64') + '/' + 
Buffer.from(secretKey).toString('base64'))
+            .then(res => {
+                if (res.status === 204) {
+                    after(res.data);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    // ConfigMaps
+    static async createConfigMap(secretName: string, after: (val: string) => 
void) {
+        instance.post('/platform/system/configmaps/' + 
Buffer.from(secretName).toString('base64'), {},{headers: {'Content-Type': 
'text/plain'}})
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    static async getConfigMaps(after: (secrets: KubernetesConfigMap[]) => 
void) {
+        instance.get('/platform/system/configmaps', {headers: {'Accept': 
'application/json'}})
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    static async deleteConfigMap(configmapName: string, after: (val: string) 
=> void) {
+        instance.delete('/platform/system/configmaps/' + 
Buffer.from(configmapName).toString('base64'))
+            .then(res => {
+                if (res.status === 204) {
+                    after(res.data);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    static async getConfigMapValue(configmapName: string, configmapKey: 
string, after: (val: string) => void) {
+        instance.get('/platform/system/configmaps/' + 
Buffer.from(configmapName).toString('base64') + '/' + 
Buffer.from(configmapKey).toString('base64'), {headers: {'Accept': 
'application/json'}})
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    static async setConfigMapValue(configmapName: string, configmapKey: 
string, value: string, after: (val: string) => void) {
+        instance.post('/platform/system/configmaps/' + 
Buffer.from(configmapName).toString('base64') + '/' + 
Buffer.from(configmapKey).toString('base64'), value, {headers: {'Content-Type': 
'text/plain'}})
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    static async deleteConfigMapValue(configmapName: string, configmapKey: 
string, after: (val: string) => void) {
+        instance.delete('/platform/system/configmaps/' + 
Buffer.from(configmapName).toString('base64') + '/' + 
Buffer.from(configmapKey).toString('base64'))
+            .then(res => {
+                if (res.status === 204) {
+                    after(res.data);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    // Env Vars
+    static async getEnvVars(after: (envVars: string[]) => void) {
+        instance.get('/ui/diagnostics/env-vars', {headers: {'Accept': 
'application/json'}})
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    static async getEnvVarValue(name: string, after: (val: string) => void) {
+        instance.get('/ui/diagnostics/env-vars/' + 
Buffer.from(name).toString('base64'), {headers: {'Accept': 'application/json'}})
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    // Application Properties
+    static async getAppProps(after: (envVars: string[]) => void) {
+        instance.get('/ui/diagnostics/app-props', {headers: {'Accept': 
'application/json'}})
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+
+    static async getAppPropValue(name: string, after: (val: string) => void) {
+        instance.get('/ui/diagnostics/app-props/' + 
Buffer.from(name).toString('base64'), {headers: {'Accept': 'application/json'}})
+            .then(res => {
+                if (res.status === 200) {
+                    after(res.data);
+                }
+            }).catch(err => {
+            ErrorEventBus.sendApiError(err);
+        });
+    }
+}
diff --git a/karavan-app/src/main/webui/src/api/auth/AuthApi.tsx 
b/karavan-app/src/main/webui/src/api/auth/AuthApi.tsx
new file mode 100644
index 00000000..7be2e164
--- /dev/null
+++ b/karavan-app/src/main/webui/src/api/auth/AuthApi.tsx
@@ -0,0 +1,253 @@
+import axios from "axios";
+import {ErrorEventBus} from "@bus/ErrorEventBus";
+import {AccessPassword, AccessUser} from "@models/AccessModels";
+import {SsoApi} from "./SsoApi"; // --- axios base ---
+
+// --- axios base ---
+axios.defaults.timeout = 30000;
+axios.defaults.headers.common["Accept"] = "application/json";
+axios.defaults.headers.common["Content-Type"] = "application/json";
+
+const instance = axios.create({ withCredentials: true });
+
+// --- simple state (no tokens) ---
+let currentUser: AccessUser | null = null;
+export function setCurrentUser(u: AccessUser | null) { currentUser = u; }
+export function getCurrentUser() { return currentUser; }
+export function getInstance() { return instance; }
+
+// --- cookies / csrf ---
+const CSRF_COOKIE = "csrf";
+function readCookie(name: string): string | null {
+    return document.cookie
+        .split("; ")
+        .map((p) => p.trim())
+        .filter((p) => p.startsWith(name + "="))
+        .map((p) => p.substring(name.length + 1))[0] ?? null;
+}
+
+function isNoAuth(cfg: any) {
+    const method = (cfg.method || "GET").toUpperCase();
+    const url = new URL(cfg.url!, cfg.baseURL || window.location.origin);
+    const path = url.pathname;
+    // Endpoints where we intentionally skip CSRF/auth headers
+    return (
+        cfg.headers?.["X-Skip-Auth"] === "1" ||
+        method === "OPTIONS" ||
+        path.endsWith("/ui/auth/login") ||
+        path.endsWith("/ui/auth/logout") ||
+        path.endsWith("/health") ||
+        path.endsWith("/q/health")
+    );
+}
+
+// --- API surface (no tokens involved) ---
+export class AuthApi {
+    static authType?: "session" | "oidc";
+    static getInstance() {
+        return instance;
+    }
+
+    static async getMe(after: (user: AccessUser | null) => void) {
+        instance
+            .get("/ui/auth/me", { withCredentials: true })
+            .then((res) => {
+                if (res.status === 200) {
+                    setCurrentUser(res.data);
+                    after(res.data);
+                } else {
+                    setCurrentUser(null);
+                    after(null);
+                }
+            })
+            .catch((err) => {
+                // 401 here means "not logged in"
+                setCurrentUser(null);
+                // Always call back: callers use it to leave their "loading" 
state,
+                // and staying loading forever would hide the login page.
+                after(null);
+                // optional: bubble error to a global bus/router
+                // ErrorEventBus.sendApiError(err);
+            });
+    }
+
+    static async login(
+        username: string,
+        password: string,
+        after: (ok: boolean, res: any) => void
+    ) {
+        instance
+            .post("/ui/auth/login", { username, password }, { withCredentials: 
true })
+            .then((res) => {
+                if (res.status === 200) {
+                    // server may return user; if not, fetch it
+                    if (res.data?.username) {
+                        setCurrentUser(res.data.username);
+                        after(true, res);
+                    } else {
+                        AuthApi.getMe(() => after(true, res));
+                    }
+                } else {
+                    setCurrentUser(null);
+                    after(false, res);
+                }
+            })
+            .catch((err) => {
+                setCurrentUser(null);
+                after(false, err);
+            });
+    }
+
+    static async logout() {
+        // Tell interceptors to skip CSRF on logout
+        setCurrentUser(null);
+        instance
+            .post(
+                "/ui/auth/logout",
+                {},
+                { withCredentials: true, headers: { "X-Skip-Auth": "1" } }
+            )
+            .then((res) => {
+                if (res.status === 204) {
+                    setCurrentUser(null);
+                }
+            })
+            .catch((err) => {
+                console.error(err);
+                setCurrentUser(null);
+            });
+    }
+
+
+    static setPassword(password: AccessPassword, after: (result: boolean, res: 
any) => void) {
+        instance
+            .post("/ui/auth/password", password)
+            .then((res) => {
+                if (res.status === 200 || res.status === 201) after(true, res);
+                else after(false, res);
+            })
+            .catch((err) => {
+                console.error(err);
+                after(false, err);
+            });
+    }
+
+    // Optional: keep if your UI still reads SSO config (even though auth is 
session-based)
+    static async getSsoConfig(after: (config: {}) => void) {
+        instance.get("/ui/auth/sso-config", { headers: { Accept: 
"application/json" } })
+            .then((res) => {
+                if (res.status === 200) after(res.data);
+            })
+            .catch((err) => {
+                ErrorEventBus.sendApiError(err);
+            });
+    }
+
+    static setAuthType(authType: "session" | "oidc") {
+        this.authType = authType;
+        switch (authType) {
+            case "oidc":
+                AuthApi.setOidcAuthentication();
+                break;
+            case "session":
+                AuthApi.setSessionIdAuthentication();
+                break;
+        }
+    }
+
+    static async getAuthType(after: (authType: string) => void) {
+        instance
+            .get("/ui/auth/type", { headers: { Accept: "text/plain" } })
+            .then((res) => {
+                if (res.status === 200) {
+                    const authType = res.data as "session" | "oidc";
+                    AuthApi.setAuthType(authType);
+                    after(authType);
+                }
+            })
+            .catch((err) => {
+                ErrorEventBus.sendApiError(err);
+            });
+    }
+
+    private static setSessionIdAuthentication() {
+        // --- request interceptor: add CSRF header on unsafe methods, never 
set Authorization ---
+        instance.interceptors.request.use((cfg: any) => {
+            // ensure no Authorization header sneaks in
+            if (cfg?.headers?.Authorization) delete cfg.headers.Authorization;
+
+            if (!isNoAuth(cfg)) {
+                const m = (cfg.method || "GET").toLowerCase();
+                const unsafe = m === "post" || m === "put" || m === "patch" || 
m === "delete";
+                if (unsafe) {
+                    const csrf = readCookie(CSRF_COOKIE);
+                    if (csrf) cfg.headers = { ...cfg.headers, "X-CSRF-Token": 
csrf };
+                }
+            }
+            return cfg;
+        });
+
+// --- response interceptor: normalize 401 handling ---
+        instance.interceptors.response.use(
+            (res) => res,
+            async (error) => {
+                const original = error?.config;
+                if (!original) throw error;
+
+                if (error?.response?.status === 401 && !original._retry && 
!isNoAuth(original)) {
+                    original._retry = true;
+                    // Session missing/expired → clear user; let caller 
redirect to /login
+                    setCurrentUser(null);
+                    // Optionally: return a rejected promise with a sentinel
+                    return Promise.reject({ ...error, _auth: "unauthorized" });
+                }
+
+                return Promise.reject(error);
+            }
+        );
+    }
+
+    static setOidcAuthentication() {
+        instance.interceptors.request.use(
+            async (config) => {
+                // Refresh proactively: reading `keycloak.token` directly 
sends an
+                // already-expired JWT for every request made after the access
+                // token lifespan (300s in the realm config) elapsed.
+                const token = await SsoApi.getValidToken();
+                if (token) config.headers.Authorization = `Bearer ${token}`;
+                return config;
+            },
+            (error) => Promise.reject(error)
+        );
+
+        instance.interceptors.response.use(
+            (response) => response,
+            async (error) => {
+                const original = error.config;
+                if (!original || original._retry) return Promise.reject(error);
+
+                const status = error?.response?.status;
+                if ((status === 401 || status === 403) && SsoApi.keycloak) {
+                    original._retry = true;
+                    try {
+                        // Backstop only - the request interceptor should 
already
+                        // have refreshed. Force a refresh (-1 = regardless of 
the
+                        // remaining validity) to cover a token revoked 
server-side.
+                        await SsoApi.updateToken(-1);
+                        const token = SsoApi.keycloak.token;
+                        if (token) {
+                            original.headers = {
+                                ...original.headers,
+                                Authorization: `Bearer ${token}`,
+                            };
+                            return instance(original);
+                        }
+                    } catch (e) {
+                        // fall-through
+                    }
+                }
+                return Promise.reject(error);
+            }
+        );
+    }
+}
diff --git a/karavan-app/src/main/webui/src/api/auth/AuthFetch.ts 
b/karavan-app/src/main/webui/src/api/auth/AuthFetch.ts
new file mode 100644
index 00000000..da8dee82
--- /dev/null
+++ b/karavan-app/src/main/webui/src/api/auth/AuthFetch.ts
@@ -0,0 +1,53 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 {AuthApi} from "./AuthApi";
+import {SsoApi} from "./SsoApi";
+
+/**
+ * `fetchEventSource` snapshots its `headers` option once and reuses that same
+ * object for every automatic reconnect. With OIDC that means the very first
+ * access token is replayed forever, so once it expires each reconnect makes 
the
+ * backend log "The JWT is no longer valid".
+ *
+ * Passing this as the `fetch` option instead makes the Authorization header be
+ * rebuilt on every attempt - including reconnects - from a token that is
+ * refreshed first when it is close to expiry.
+ */
+export function authFetch(input: RequestInfo | URL, init?: RequestInit): 
Promise<Response> {
+    if (AuthApi.authType !== 'oidc') {
+        return window.fetch(input, init);
+    }
+    return SsoApi.getValidToken().then(token => {
+        const headers = new Headers(init?.headers);
+        if (token) {
+            headers.set('Authorization', 'Bearer ' + token);
+        } else {
+            headers.delete('Authorization');
+        }
+        return window.fetch(input, {...init, headers});
+    });
+}
+
+/**
+ * Reconnect backoff for SSE streams. `fetchEventSource` retries every second 
by
+ * default, which turns any persistent failure into a request flood (and a WARN
+ * flood in the backend log). Returned from `onerror` to space attempts out.
+ */
+export function sseRetryInterval(attempt: number): number {
+    return Math.min(1000 * Math.pow(2, attempt), 30000);
+}
diff --git a/karavan-app/src/main/webui/src/api/auth/AuthProvider.tsx 
b/karavan-app/src/main/webui/src/api/auth/AuthProvider.tsx
new file mode 100644
index 00000000..2e11e5fb
--- /dev/null
+++ b/karavan-app/src/main/webui/src/api/auth/AuthProvider.tsx
@@ -0,0 +1,103 @@
+import React, {useCallback, useEffect, useState} from "react";
+// Import all the APIs we need
+import {AuthApi, getCurrentUser} from "./AuthApi";
+import {SsoApi} from "./SsoApi";
+import {AccessUser} from "@models/AccessModels"; // Assuming AccessUser is here
+
+// We'll add `authType` to the context for consumers
+export const AuthContext = React.createContext({
+    user: null as AccessUser | null,
+    loading: true,
+    authType: null as "session" | "oidc" | null,
+    reload: async () => {},
+    logout: async () => {},
+});
+
+export function AuthProvider({ children }: { children: React.ReactNode }) {
+    const [user, setUser] = useState<AccessUser | null>(null);
+    const [loading, setLoading] = useState(true);
+    const [authType, setAuthType] = useState<"session" | "oidc" | null>(null);
+
+    // This is the main initialization effect.
+    // It replaces the simple `useEffect(() => { reload() }, [reload])`
+    useEffect(() => {
+        let isMounted = true; // Prevent state updates on unmounted component
+        // 1. Fetch the authentication type.
+        // This call also sets up the correct axios interceptors inside 
AuthApi.
+        AuthApi.getAuthType(type => {
+            if (!isMounted) return;
+
+            setAuthType(type as "session" | "oidc" | null); // Save the type
+
+            if (type === 'oidc') {
+                // 2a. OIDC Flow: Initialize Keycloak.
+                // SsoApi.auth() handles the 'check-sso' logic.
+                SsoApi.auth(() => {
+                    // This callback runs after Keycloak init (login or silent 
check)
+                    if (isMounted) {
+                        // SsoApi.auth() has updated the global currentUser
+                        setUser(getCurrentUser());
+                        setLoading(false);
+                    }
+                });
+            } else {
+                // 2b. Session Flow: Just call /me to check for an existing 
cookie.
+                AuthApi.getMe(userFromApi => {
+                    if (isMounted) {
+                        // AuthApi.getMe() updates global user and returns it
+                        setUser(userFromApi);
+                        setLoading(false);
+                    }
+                });
+            }
+        });
+
+        // Cleanup function in case component unmounts during auth
+        return () => { isMounted = false; };
+    }, []); // Run only once on mount
+
+    // The original `reload` was flawed (awaiting a non-promise).
+    // This implementation matches the callback-style of AuthApi.getMe.
+    const reload = useCallback(async () => {
+        console.log("reload", getCurrentUser());
+        setLoading(true);
+        // getMe will use the correct interceptor (OIDC or session)
+        // because getAuthType() already ran and set it up.
+        AuthApi.getMe((u) => {
+            setUser(u);
+            setLoading(false);
+        });
+        // We keep the `async` signature to match the context interface,
+        // even though the implementation is callback-based.
+    }, []);
+
+    // The logout function MUST now be conditional
+    const logout = useCallback(async () => {
+        setLoading(true);
+        if (authType === 'oidc') {
+            // Use OIDC logout
+            SsoApi.logout(() => {
+                setUser(null);
+                setLoading(false);
+                // OIDC logout often involves a page redirect,
+                // which SsoApi.logout() will trigger.
+            });
+        } else if (authType === 'session') {
+            // Use session logout
+            await AuthApi.logout(); // This is async
+            setUser(null);
+            setLoading(false);
+            window.location.reload();
+        } else {
+            // Fallback if authType isn't set for some reason
+            setUser(null);
+            setLoading(false);
+        }
+    }, [authType]); // Re-create this function if authType changes
+
+    return (
+        <AuthContext.Provider value={{ user, loading, authType, reload, logout 
}}>
+            {children}
+        </AuthContext.Provider>
+    );
+}
\ No newline at end of file
diff --git a/karavan-app/src/main/webui/src/api/auth/SsoApi.tsx 
b/karavan-app/src/main/webui/src/api/auth/SsoApi.tsx
new file mode 100644
index 00000000..447b9fc5
--- /dev/null
+++ b/karavan-app/src/main/webui/src/api/auth/SsoApi.tsx
@@ -0,0 +1,157 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 Keycloak from "keycloak-js";
+import {AuthApi, setCurrentUser} from "./AuthApi";
+import {AccessUser} from "@models/AccessModels";
+
+// Refresh the access token when it has less than this many seconds left.
+// Must be comfortably larger than the polling/SSE reconnect period so a token
+// never reaches the backend after its `exp`.
+const MIN_VALIDITY_SECONDS = 70;
+
+// How often the background watchdog checks the token. Keycloak only fires
+// `onTokenExpired` while the tab is alive; the interval also covers the case
+// where the tab was suspended (laptop sleep, background tab throttling).
+const REFRESH_CHECK_MS = 20_000;
+
+export class SsoApi {
+
+    static keycloak?: Keycloak;
+
+    private static refreshTimer?: number;
+    // De-duplicates concurrent refreshes: dozens of pollers can ask for a 
token
+    // in the same tick, and Keycloak must not be hit with parallel refresh 
calls
+    // (with `refreshTokenMaxReuse=0` the losers would invalidate the session).
+    private static refreshPromise?: Promise<boolean>;
+
+    static auth(after: () => void) {
+        AuthApi.getSsoConfig((config: any) => {
+            SsoApi.keycloak = new Keycloak({url: config.url, realm: 
config.realm, clientId: config.clientId});
+            SsoApi.keycloak.onTokenExpired = () => {
+                console.log('SsoApi', 'Access token expired, refreshing.');
+                SsoApi.updateToken().catch(reason => console.log('SsoApi', 
'Refresh on expiry failed:', reason));
+            };
+            SsoApi.keycloak.init({
+                flow: "standard",
+                pkceMethod: "S256",
+                onLoad: 'login-required',
+                checkLoginIframe: false,
+                silentCheckSsoRedirectUri: 
`${location.origin}/silent-check-sso.html`
+            }).then(authenticated => {
+                if (authenticated) {
+                    const k = SsoApi.keycloak;
+                    if (k) {
+                        const userInfo = {
+                            username: k.tokenParsed?.preferred_username,
+                            roles: k.tokenParsed?.realm_access?.roles || [],
+                        };
+                        console.log('SsoApi', 'User is now authenticated.', 
userInfo);
+                        setCurrentUser(userInfo as AccessUser);
+                        SsoApi.startRefreshWatchdog();
+                    }
+                } else {
+                    console.log('User is not authenticated');
+                }
+                after();
+            }).catch(reason => {
+                console.log('SsoApi', 'Error:', reason);
+                // Still notify the caller so it can leave its "loading" state
+                // instead of hanging on a blank screen.
+                after();
+            });
+        });
+    }
+
+    /**
+     * Refreshes the access token if it expires within `MIN_VALIDITY_SECONDS`.
+     * Concurrent callers share a single in-flight refresh.
+     * Resolves to true when a new token was actually fetched.
+     */
+    static updateToken(minValidity: number = MIN_VALIDITY_SECONDS): 
Promise<boolean> {
+        const k = SsoApi.keycloak;
+        if (!k?.authenticated) {
+            return Promise.resolve(false);
+        }
+        if (!SsoApi.refreshPromise) {
+            SsoApi.refreshPromise = k.updateToken(minValidity)
+                .catch(reason => {
+                    // The refresh token itself is gone (SSO idle timeout / 
session
+                    // revoked). Nothing to salvage: bounce the user to the IdP
+                    // instead of hammering the backend with a dead access 
token.
+                    console.log('SsoApi', 'Token refresh failed, 
re-authenticating:', reason);
+                    SsoApi.stopRefreshWatchdog();
+                    setCurrentUser(null);
+                    k.login();
+                    return false;
+                })
+                .finally(() => {
+                    SsoApi.refreshPromise = undefined;
+                });
+        }
+        return SsoApi.refreshPromise;
+    }
+
+    /**
+     * Single entry point for anything that needs to put a bearer token on a
+     * request. Always returns a token that is still valid, refreshing first
+     * when needed, so an expired JWT never reaches the backend.
+     */
+    static async getValidToken(): Promise<string | undefined> {
+        const k = SsoApi.keycloak;
+        if (!k?.authenticated) {
+            return undefined;
+        }
+        await SsoApi.updateToken();
+        return SsoApi.keycloak?.token;
+    }
+
+    private static startRefreshWatchdog() {
+        SsoApi.stopRefreshWatchdog();
+        SsoApi.refreshTimer = window.setInterval(() => {
+            SsoApi.updateToken().catch(reason => console.log('SsoApi', 
'Scheduled refresh failed:', reason));
+        }, REFRESH_CHECK_MS);
+        // A suspended tab misses its intervals; re-check as soon as it is 
visible again.
+        document.addEventListener('visibilitychange', 
SsoApi.onVisibilityChange);
+    }
+
+    private static stopRefreshWatchdog() {
+        if (SsoApi.refreshTimer !== undefined) {
+            window.clearInterval(SsoApi.refreshTimer);
+            SsoApi.refreshTimer = undefined;
+        }
+        document.removeEventListener('visibilitychange', 
SsoApi.onVisibilityChange);
+    }
+
+    private static onVisibilityChange = () => {
+        if (!document.hidden) {
+            SsoApi.updateToken().catch(reason => console.log('SsoApi', 
'Refresh on resume failed:', reason));
+        }
+    };
+
+    static logout(after: () => void) {
+        if (SsoApi.keycloak) {
+            SsoApi.stopRefreshWatchdog();
+            SsoApi.keycloak.logout().then(value => {
+                console.log('SsoApi', 'User is now logout.');
+                setCurrentUser(null)
+            }).catch(reason => {
+                console.log('SsoApi', 'Error:', reason);
+            });
+        }
+    }
+}

Reply via email to