module federation

This commit is contained in:
2025-08-26 20:32:13 -04:00
parent a7d5425124
commit 2772dcc966
46 changed files with 52051 additions and 103 deletions

36
web/src/App.tsx Normal file
View File

@ -0,0 +1,36 @@
import React, { useEffect } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { Layout } from 'antd';
import ShellHeader from './components/ShellHeader';
import AppContainer from './components/AppContainer';
import LoadKMS from './components/LoadKMS';
import { useApp } from './contexts/AppContext';
const { Content } = Layout;
const App: React.FC = () => {
const { setUser } = useApp();
useEffect(() => {
setUser({
email: 'admin@example.com',
name: 'Admin User',
});
}, [setUser]);
return (
<Layout className="shell-layout">
<LoadKMS />
<ShellHeader />
<Content className="shell-content">
<Routes>
<Route path="/kms/*" element={<AppContainer />} />
<Route path="/" element={<Navigate to="/kms" replace />} />
<Route path="*" element={<AppContainer />} />
</Routes>
</Content>
</Layout>
);
};
export default App;

23
web/src/bootstrap.tsx Normal file
View File

@ -0,0 +1,23 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { ConfigProvider } from 'antd';
import App from './App';
import { AppProvider } from './contexts/AppContext';
import './index.css';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<BrowserRouter>
<ConfigProvider>
<AppProvider>
<App />
</AppProvider>
</ConfigProvider>
</BrowserRouter>
</React.StrictMode>
);

View File

@ -0,0 +1,96 @@
import React, { Suspense, useEffect } from 'react';
import { Tabs, Spin } from 'antd';
import { useApp } from '../contexts/AppContext';
import { FederatedApp } from '../types';
import { useLocation, useNavigate } from 'react-router-dom';
const AppContainer: React.FC = () => {
const { apps, activeAppId, setActiveApp } = useApp();
const location = useLocation();
const navigate = useNavigate();
// Auto-detect active app based on current URL path
useEffect(() => {
if (apps.length > 0) {
const currentPath = location.pathname;
// Find app that matches the current path
const matchingApp = apps.find(app => currentPath.startsWith(app.path));
if (matchingApp && activeAppId !== matchingApp.id) {
setActiveApp(matchingApp.id);
} else if (!activeAppId && !matchingApp) {
// Default to first app if no match
setActiveApp(apps[0].id);
navigate(apps[0].path, { replace: true });
}
}
}, [location.pathname, apps, activeAppId, setActiveApp, navigate]);
if (apps.length === 0) {
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100%',
fontSize: '16px',
color: '#666'
}}>
No applications available
</div>
);
}
const handleTabChange = (key: string) => {
const selectedApp = apps.find(app => app.id === key);
if (selectedApp) {
setActiveApp(key);
navigate(selectedApp.path);
}
};
const renderAppContent = (app: FederatedApp) => {
const AppComponent = app.component;
return (
<Suspense
fallback={
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '400px'
}}>
<Spin size="large" />
</div>
}
>
<div style={{ height: '100%', overflow: 'auto' }}>
<AppComponent />
</div>
</Suspense>
);
};
const tabItems = apps.map(app => ({
key: app.id,
label: app.name,
children: renderAppContent(app)
}));
return (
<div style={{ height: '100%', padding: '0' }}>
<Tabs
activeKey={activeAppId || (apps.length > 0 ? apps[0].id : undefined)}
onChange={handleTabChange}
className="app-tabs"
type="card"
style={{ height: '100%' }}
items={tabItems}
tabBarStyle={{ margin: 0, padding: '0 16px' }}
/>
</div>
);
};
export default AppContainer;

View File

@ -0,0 +1,98 @@
import React, { useState, useCallback } from 'react';
import { Input, Dropdown, Spin, Empty, Typography } from 'antd';
import { SearchOutlined } from '@ant-design/icons';
import { useApp } from '../contexts/AppContext';
import { SearchResult } from '../types';
const { Text } = Typography;
const GlobalSearch: React.FC = () => {
const [query, setQuery] = useState('');
const [isOpen, setIsOpen] = useState(false);
const { searchResults, isSearching, performSearch, clearSearch } = useApp();
const handleSearch = useCallback(
async (value: string) => {
setQuery(value);
if (value.trim()) {
await performSearch(value);
setIsOpen(true);
} else {
clearSearch();
setIsOpen(false);
}
},
[performSearch, clearSearch]
);
const handleResultClick = (result: SearchResult) => {
if (result.action) {
result.action();
}
setIsOpen(false);
setQuery('');
clearSearch();
};
const searchContent = (
<div className="search-dropdown" style={{ minWidth: 400 }}>
{isSearching ? (
<div style={{ padding: '20px', textAlign: 'center' }}>
<Spin />
</div>
) : searchResults.length > 0 ? (
searchResults.map((result) => (
<div
key={result.id}
className="search-result-item"
onClick={() => handleResultClick(result)}
style={{ cursor: 'pointer' }}
>
<div className="search-result-title">{result.title}</div>
{result.description && (
<div className="search-result-description">{result.description}</div>
)}
<div className="search-result-app">
<Text type="secondary">{result.appId}</Text>
</div>
</div>
))
) : query ? (
<div style={{ padding: '20px' }}>
<Empty
description="No results found"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
</div>
) : null}
</div>
);
return (
<Dropdown
open={isOpen && (isSearching || searchResults.length > 0 || query.length > 0)}
onOpenChange={setIsOpen}
dropdownRender={() => searchContent}
trigger={['click']}
placement="bottomLeft"
>
<Input
size="middle"
placeholder="Search across all applications..."
prefix={<SearchOutlined />}
value={query}
onChange={(e) => handleSearch(e.target.value)}
onPressEnter={() => handleSearch(query)}
style={{ width: 400 }}
allowClear
onClear={() => {
setQuery('');
clearSearch();
setIsOpen(false);
}}
/>
</Dropdown>
);
};
export default GlobalSearch;

View File

@ -0,0 +1,66 @@
import React, { useEffect, useRef } from 'react';
import { useApp } from '../contexts/AppContext';
const LoadKMS: React.FC = () => {
const { registerApp } = useApp();
const registeredRef = useRef(false);
useEffect(() => {
const loadKMS = async () => {
if (registeredRef.current) return;
try {
console.log('Loading KMS app...');
// Dynamically import the KMS app
const KMSApp = React.lazy(() => import('kms/App'));
// Try to load search provider separately
let kmsSearchProvider;
try {
const SearchModule = await import('kms/SearchProvider');
kmsSearchProvider = SearchModule.kmsSearchProvider;
} catch (searchError) {
console.warn('Failed to load KMS search provider:', searchError);
kmsSearchProvider = undefined;
}
registerApp({
id: 'kms',
name: 'KMS',
path: '/kms',
component: KMSApp,
searchProvider: kmsSearchProvider,
});
registeredRef.current = true;
console.log('KMS app loaded successfully');
} catch (error) {
console.error('Failed to load KMS app:', error);
// Try to register with just basic import as fallback
try {
const KMSApp = React.lazy(() => import('kms/App'));
registerApp({
id: 'kms',
name: 'KMS',
path: '/kms',
component: KMSApp,
});
registeredRef.current = true;
console.log('KMS app loaded with fallback');
} catch (fallbackError) {
console.error('Complete KMS loading failure:', fallbackError);
}
}
};
loadKMS();
}, [registerApp]);
return null;
};
export default LoadKMS;

View File

@ -0,0 +1,41 @@
import React from 'react';
import { Layout, Space, Typography } from 'antd';
import GlobalSearch from './GlobalSearch';
import TimeZoneClock from './TimeZoneClock';
import UserDropdown from './UserDropdown';
const { Header } = Layout;
const { Title } = Typography;
const ShellHeader: React.FC = () => {
return (
<Header className="shell-header" style={{
padding: '0 24px',
background: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
borderBottom: '1px solid #f0f0f0'
}}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Title level={4} style={{ margin: 0, marginRight: 32 }}>
Skybridge Platform
</Title>
<GlobalSearch />
</div>
<Space size="large">
<div className="timezone-clocks">
<TimeZoneClock timezone="America/Los_Angeles" label="PST" />
<TimeZoneClock timezone="America/New_York" label="EST" />
<TimeZoneClock timezone="America/Chicago" label="CST" />
<TimeZoneClock timezone="Asia/Kolkata" label="IST" />
</div>
<UserDropdown />
</Space>
</Header>
);
};
export default ShellHeader;

View File

@ -0,0 +1,28 @@
import React, { useState, useEffect } from 'react';
import moment from 'moment-timezone';
import { TimeZoneClockProps } from '../types';
const TimeZoneClock: React.FC<TimeZoneClockProps> = ({ timezone, label }) => {
const [time, setTime] = useState(moment.tz(timezone));
useEffect(() => {
const interval = setInterval(() => {
setTime(moment.tz(timezone));
}, 1000);
return () => clearInterval(interval);
}, [timezone]);
return (
<div className="timezone-clock">
<div className="timezone-clock-time">
{time.format('HH:mm')}
</div>
<div className="timezone-clock-label">
{label}
</div>
</div>
);
};
export default TimeZoneClock;

View File

@ -0,0 +1,44 @@
import React from 'react';
import { Dropdown, Avatar, Typography, Space } from 'antd';
import { UserOutlined, LogoutOutlined } from '@ant-design/icons';
import type { MenuProps } from 'antd';
import { useApp } from '../contexts/AppContext';
const { Text } = Typography;
const UserDropdown: React.FC = () => {
const { user, logout } = useApp();
if (!user) return null;
const menuItems: MenuProps['items'] = [
{
key: 'logout',
icon: <LogoutOutlined />,
label: 'Logout',
onClick: logout,
},
];
return (
<Dropdown menu={{ items: menuItems }} placement="bottomRight">
<Space style={{ cursor: 'pointer' }}>
<Avatar
size="small"
icon={<UserOutlined />}
src={user.avatar}
/>
<div style={{ textAlign: 'left' }}>
<div>{user.name || user.email}</div>
{user.name && (
<Text type="secondary" style={{ fontSize: '12px' }}>
{user.email}
</Text>
)}
</div>
</Space>
</Dropdown>
);
};
export default UserDropdown;

View File

@ -0,0 +1,108 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
import { FederatedApp, User, SearchResult } from '../types';
interface AppContextType {
user: User | null;
apps: FederatedApp[];
activeAppId: string | null;
searchResults: SearchResult[];
isSearching: boolean;
setUser: (user: User | null) => void;
registerApp: (app: FederatedApp) => void;
setActiveApp: (appId: string | null) => void;
performSearch: (query: string) => Promise<void>;
clearSearch: () => void;
logout: () => void;
}
const AppContext = createContext<AppContextType | undefined>(undefined);
interface AppProviderProps {
children: ReactNode;
}
export const AppProvider: React.FC<AppProviderProps> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [apps, setApps] = useState<FederatedApp[]>([]);
const [activeAppId, setActiveAppId] = useState<string | null>(null);
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [isSearching, setIsSearching] = useState(false);
const registerApp = (app: FederatedApp) => {
setApps(prev => {
const exists = prev.find(a => a.id === app.id);
if (exists) return prev;
return [...prev, app];
});
};
const setActiveApp = (appId: string | null) => {
setActiveAppId(appId);
};
const performSearch = async (query: string) => {
if (!query.trim()) {
setSearchResults([]);
return;
}
setIsSearching(true);
const allResults: SearchResult[] = [];
try {
await Promise.all(
apps.map(async (app) => {
if (app.searchProvider) {
try {
const results = await app.searchProvider(query);
allResults.push(...results);
} catch (error) {
console.error(`Search failed for app ${app.id}:`, error);
}
}
})
);
setSearchResults(allResults);
} finally {
setIsSearching(false);
}
};
const clearSearch = () => {
setSearchResults([]);
};
const logout = () => {
setUser(null);
setActiveAppId(null);
};
return (
<AppContext.Provider
value={{
user,
apps,
activeAppId,
searchResults,
isSearching,
setUser,
registerApp,
setActiveApp,
performSearch,
clearSearch,
logout,
}}
>
{children}
</AppContext.Provider>
);
};
export const useApp = (): AppContextType => {
const context = useContext(AppContext);
if (context === undefined) {
throw new Error('useApp must be used within an AppProvider');
}
return context;
};

101
web/src/index.css Normal file
View File

@ -0,0 +1,101 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
* {
box-sizing: border-box;
}
#root {
height: 100vh;
display: flex;
flex-direction: column;
}
.shell-layout {
display: flex;
flex-direction: column;
height: 100vh;
}
.shell-header {
flex: 0 0 auto;
border-bottom: 1px solid #f0f0f0;
background: #fff;
z-index: 1000;
}
.shell-content {
flex: 1;
overflow: hidden;
}
.timezone-clocks {
display: flex;
gap: 24px;
align-items: center;
}
.timezone-clock {
text-align: center;
font-size: 12px;
}
.timezone-clock-time {
font-weight: 600;
font-size: 14px;
margin-bottom: 2px;
}
.timezone-clock-label {
color: #666;
font-size: 11px;
}
.search-dropdown {
max-height: 400px;
overflow-y: auto;
}
.search-result-item {
padding: 8px 12px;
border-bottom: 1px solid #f0f0f0;
}
.search-result-item:last-child {
border-bottom: none;
}
.search-result-title {
font-weight: 500;
margin-bottom: 4px;
}
.search-result-description {
font-size: 12px;
color: #666;
}
.search-result-app {
font-size: 11px;
color: #999;
margin-top: 4px;
}
.app-tabs .ant-tabs-content-holder {
padding: 0;
}
.app-tabs .ant-tabs-tabpane {
height: 100%;
}

3
web/src/index.tsx Normal file
View File

@ -0,0 +1,3 @@
import('./bootstrap');
export {};

10
web/src/types/federated.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
declare module 'kms/App' {
import React from 'react';
const KMSApp: React.ComponentType;
export default KMSApp;
}
declare module 'kms/SearchProvider' {
import { SearchResult } from './index';
export const kmsSearchProvider: (query: string) => Promise<SearchResult[]>;
}

26
web/src/types/index.ts Normal file
View File

@ -0,0 +1,26 @@
export interface FederatedApp {
id: string;
name: string;
path: string;
component: React.ComponentType;
searchProvider?: (query: string) => Promise<SearchResult[]>;
}
export interface SearchResult {
id: string;
title: string;
description?: string;
appId: string;
action?: () => void;
}
export interface User {
email: string;
name?: string;
avatar?: string;
}
export interface TimeZoneClockProps {
timezone: string;
label: string;
}