diff --git a/src/App.tsx b/src/App.tsx index 69dd708..997e782 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,10 @@ import { Navigate, Route, Routes } from 'react-router-dom' +import { useTheme } from './hooks/useTheme' import Login from './pages/Login' import Register from './pages/Register' import DashboardLayout from './layout/DashboardLayout' import AuthGate from './middleware/AuthGate' +import Landing from './pages/Landing' import CreateCluster from './pages/CreateCluster' import ClusterDetail from './pages/ClusterDetail' import Namespaces from './pages/Namespaces' @@ -23,11 +25,14 @@ import StorageClasses from './pages/StorageClasses' import ServiceAccounts from './pages/ServiceAccounts' import Settings from './pages/Settings' import Resources from './pages/Resources' +import HelmApps from './pages/HelmApps' function App() { + // Initialize theme on app load + useTheme() return ( - } /> + } /> } /> } /> }> @@ -53,6 +58,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/src/hooks/useTheme.ts b/src/hooks/useTheme.ts new file mode 100644 index 0000000..dd66d86 --- /dev/null +++ b/src/hooks/useTheme.ts @@ -0,0 +1,50 @@ +import { useEffect, useState } from 'react' + +// Cookie utility functions +const setCookie = (name: string, value: string, days: number = 365) => { + const expires = new Date() + expires.setTime(expires.getTime() + (days * 24 * 60 * 60 * 1000)) + document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/` +} + +const getCookie = (name: string): string | null => { + const nameEQ = name + "=" + const ca = document.cookie.split(';') + for (let i = 0; i < ca.length; i++) { + let c = ca[i] + while (c.charAt(0) === ' ') c = c.substring(1, c.length) + if (c.indexOf(nameEQ) === 0) return c.substring(nameEQ.length, c.length) + } + return null +} + +// Apply theme to document +const applyTheme = (theme: 'light' | 'dark') => { + document.documentElement.classList.remove('light', 'dark') + document.documentElement.classList.add(theme) + document.documentElement.setAttribute('data-theme', theme) +} + +export const useTheme = () => { + const [theme, setTheme] = useState<'light' | 'dark'>('light') + + useEffect(() => { + // Get theme from cookie on mount + const storedTheme = getCookie('theme') as 'light' | 'dark' | null + if (storedTheme) { + setTheme(storedTheme) + applyTheme(storedTheme) + } else { + // Default to light theme and apply it + applyTheme('light') + } + }, []) + + const changeTheme = (newTheme: 'light' | 'dark') => { + setTheme(newTheme) + setCookie('theme', newTheme) + applyTheme(newTheme) + } + + return { theme, changeTheme } +} diff --git a/src/index.css b/src/index.css index 5a07341..1735aff 100644 --- a/src/index.css +++ b/src/index.css @@ -4,10 +4,81 @@ html, body, #root { height: 100%; } + +/* Light theme (default) */ body { @apply bg-gray-50 text-gray-900; } +/* Dark theme */ +.dark body { + @apply bg-gray-900 text-gray-100; +} + +.dark .bg-white { + @apply bg-gray-800; +} + +.dark .border-gray-200 { + @apply border-gray-700; +} + +.dark .text-gray-900 { + @apply text-gray-100; +} + +.dark .text-gray-700 { + @apply text-gray-300; +} + +.dark .text-gray-600 { + @apply text-gray-400; +} + +.dark .text-gray-500 { + @apply text-gray-500; +} + +.dark .bg-gray-50 { + @apply bg-gray-800; +} + +.dark .hover\:bg-gray-50:hover { + @apply hover:bg-gray-700; +} + +.dark .border-gray-300 { + @apply border-gray-600; +} + +.dark .focus\:ring-blue-500:focus { + @apply focus:ring-blue-400; +} + +.dark .bg-blue-50 { + @apply bg-blue-900; +} + +.dark .border-blue-200 { + @apply border-blue-700; +} + +.dark .text-blue-800 { + @apply text-blue-200; +} + +.dark .text-blue-700 { + @apply text-blue-300; +} + +.dark .border-blue-300 { + @apply border-blue-600; +} + +.dark .hover\:bg-blue-100:hover { + @apply hover:bg-blue-800; +} + /* shadcn-like utilities */ .container { @apply mx-auto max-w-7xl px-4 sm:px-6 lg:px-8; diff --git a/src/layout/DashboardLayout.tsx b/src/layout/DashboardLayout.tsx index 59a63b5..73d2594 100644 --- a/src/layout/DashboardLayout.tsx +++ b/src/layout/DashboardLayout.tsx @@ -64,10 +64,10 @@ export default function DashboardLayout() { } > - Namespaces (4) + Namespaces - `flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium ${ @@ -77,7 +77,7 @@ export default function DashboardLayout() { > Nodes (3) - + */} - Pods (12) + Pods - Deployments (8) + Deployments - ReplicaSets (12) + ReplicaSets - StatefulSets (3) + StatefulSets - DaemonSets (2) + DaemonSets - Jobs (5) + Jobs - CronJobs (4) + CronJobs - ReplicationControllers (1) + ReplicationControllers - Services (6) + Services - ConfigMaps (15) + ConfigMaps - Secrets (9) + Secrets - PersistentVolumes (5) + PersistentVolumes - StorageClasses (3) + StorageClasses - ServiceAccounts (7) + ServiceAccounts @@ -260,6 +260,17 @@ export default function DashboardLayout() { Resources + + `flex items-center gap-2 rounded-md px-3 py-2 text-sm font-medium ${ + isActive ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-50' + }` + } + > + + Helm Apps + @@ -276,6 +287,8 @@ export default function DashboardLayout() { - - @@ -330,6 +558,50 @@ export default function ClusterDetail() { + + {/* Events Right Panel */} + {isEventsOpen && ( +
+
+

Cluster Events

+ +
+
+ {isLoadingEvents ? ( +
Loading events...
+ ) : events.length === 0 ? ( +
No events found.
+ ) : ( +
+ {events.map((e, idx) => ( +
+
+ {e.Type} + {e.Age} +
+
{e.Reason}
+
{e.Message}
+
+
Object: {e.ObjectKind}/{e.ObjectName}
+
Namespace: {e.Namespace || '-'}
+
Count: {e.Count}
+
First Seen: {e.FirstSeen}
+
Last Seen: {e.LastSeen}
+
+
+ ))} +
+ )} +
+
+ +
+
+ )} ) } diff --git a/src/pages/ConfigMaps.tsx b/src/pages/ConfigMaps.tsx index 0e50bf8..a441ce2 100644 --- a/src/pages/ConfigMaps.tsx +++ b/src/pages/ConfigMaps.tsx @@ -1,46 +1,246 @@ +import { useEffect, useRef, useState } from 'react' +import { useNavigate } from 'react-router-dom' + export default function ConfigMaps() { - const configmaps = [ - { - name: 'app-config', - namespace: 'default', - data: 3, - age: '2h', - labels: 'app=web' - }, - { - name: 'database-config', - namespace: 'default', - data: 5, - age: '1d', - labels: 'app=database' - }, - { - name: 'redis-config', - namespace: 'default', - data: 2, - age: '3h', - labels: 'app=redis' - }, - ] + const navigate = useNavigate() + interface ConfigMapItem { + Name: string + Namespace: string + Data: string | number + Age: string + Labels: string + } + + interface Namespace { + Name: string + Status: string + Age: string + } + + const [configmaps, setConfigMaps] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [clusterName, setClusterName] = useState('') + const [selectedNamespace, setSelectedNamespace] = useState('') + const [namespaces, setNamespaces] = useState([]) + // View sidebar + const [isSidebarOpen, setIsSidebarOpen] = useState(false) + const [selectedForManifest, setSelectedForManifest] = useState(null) + const [manifest, setManifest] = useState('') + const [isLoadingManifest, setIsLoadingManifest] = useState(false) + const isLoadingManifestRef = useRef(false) + // Create sidebar + const [isCreateSidebarOpen, setIsCreateSidebarOpen] = useState(false) + const [createManifest, setCreateManifest] = useState('') + const isCreatingRef = useRef(false) + // Edit sidebar + // Edit sidebar (disabled for now) + // const [isEditSidebarOpen, setIsEditSidebarOpen] = useState(false) + // const [editManifest, setEditManifest] = useState('') + // const isEditingRef = useRef(false) + // const [selectedForEdit, setSelectedForEdit] = useState(null) + // Delete modal + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) + const [isDeleting, setIsDeleting] = useState(false) + const isDeletingRef = useRef(false) + const [selectedForDelete, setSelectedForDelete] = useState(null) + + const fetchNamespaces = async () => { + if (!clusterName) return + try { + const response = await fetch(`http://localhost:8082/cluster_namespaces?Name=${encodeURIComponent(clusterName)}`, { + method: 'GET', + headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` } + }) + if (response.ok) { + const data = await response.json() + setNamespaces(data || []) + if (data && data.length > 0 && !selectedNamespace) setSelectedNamespace(data[0].Name) + } else if (response.status === 401) { navigate('/login') } + } catch (err) { + console.error('Failed to fetch namespaces', err) + } + } + + const fetchConfigMaps = async (namespace?: string) => { + if (!clusterName) return + try { + setIsLoading(true) + const url = namespace + ? `http://localhost:8082/cluster_configmap?Name=${encodeURIComponent(clusterName)}&Namespace=${encodeURIComponent(namespace)}` + : `http://localhost:8082/cluster_configmap?Name=${encodeURIComponent(clusterName)}` + const response = await fetch(url, { + method: 'GET', + headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` } + }) + if (response.ok) { + const data = await response.json() + setConfigMaps(data || []) + } else if (response.status === 401) { navigate('/login') } else { + const data = await response.json() + console.error(data.message || 'Failed to fetch configmaps') + } + } catch (err) { + console.error('Failed to fetch configmaps', err) + } finally { + setIsLoading(false) + } + } + + const handleNamespaceChange = (ns: string) => { + setSelectedNamespace(ns) + fetchConfigMaps(ns) + } + + // Utility + const encodeBase64Utf8 = (input: string): string => { + try { return btoa(unescape(encodeURIComponent(input))) } catch (_err) { + const bytes = new TextEncoder().encode(input) + let binary = '' + const chunkSize = 0x8000 + for (let i = 0; i < bytes.length; i += chunkSize) { + const chunk = bytes.subarray(i, i + chunkSize) + binary += String.fromCharCode.apply(null, Array.from(chunk)) + } + return btoa(binary) + } + } + + // View + const handleViewClick = async (cm: ConfigMapItem) => { + if (!clusterName) return + if (isLoadingManifestRef.current) return + isLoadingManifestRef.current = true + setIsSidebarOpen(true) + setSelectedForManifest(cm) + setIsLoadingManifest(true) + try { + const res = await fetch('http://localhost:8082/configmap_manifest', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` }, + body: JSON.stringify({ Clustername: clusterName, Namespace: cm.Namespace, Configmapname: cm.Name }) + }) + if (res.ok) { + const data = await res.json() + setManifest(typeof data === 'string' ? data.replace(/\\n/g, '\n') : JSON.stringify(data, null, 2)) + } else if (res.status === 401) { navigate('/login') } else { + const data = await res.json() + setManifest('Failed to fetch manifest: ' + (data.message || 'Unknown error')) + } + } catch (err) { + console.error('Failed to fetch configmap manifest', err) + setManifest('Failed to fetch manifest: ' + err) + } finally { + isLoadingManifestRef.current = false + setIsLoadingManifest(false) + } + } + + // Create + const openCreate = () => { setIsCreateSidebarOpen(true); setCreateManifest('') } + const submitCreate = async () => { + if (!createManifest.trim() || !clusterName) { alert('Please enter a configmap manifest'); return } + if (isCreatingRef.current) return + isCreatingRef.current = true + try { + const encoded = encodeBase64Utf8(createManifest) + const response = await fetch('http://localhost:8082/configmap_create', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` }, + body: JSON.stringify({ Clustername: clusterName, Manifest: encoded }) + }) + if (response.ok) { + setIsCreateSidebarOpen(false) + setCreateManifest('') + await fetchConfigMaps(selectedNamespace) + alert('ConfigMap created successfully!') + } else if (response.status === 401) { navigate('/login') } else { + const data = await response.json() + alert('Failed to create configmap: ' + (data.message || 'Unknown error')) + } + } catch (err) { + console.error('Failed to create configmap', err) + alert('Failed to create configmap: ' + err) + } finally { + isCreatingRef.current = false + } + } + + // Edit + // const openEdit = async (cm: ConfigMapItem) => { /* disabled */ } + // const submitEdit = async () => { /* disabled */ } + + // Delete + const openDelete = (cm: ConfigMapItem) => { setSelectedForDelete(cm); setIsDeleteModalOpen(true) } + const closeDelete = () => { setIsDeleteModalOpen(false); setSelectedForDelete(null) } + const submitDelete = async () => { + if (!selectedForDelete || !clusterName) return + if (isDeletingRef.current) return + isDeletingRef.current = true + setIsDeleting(true) + try { + const url = `http://localhost:8082/configmap_delete?Name=${encodeURIComponent(clusterName)}&Namespace=${encodeURIComponent(selectedForDelete.Namespace)}&configmapName=${encodeURIComponent(selectedForDelete.Name)}` + const response = await fetch(url, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json', 'Authorization': `${localStorage.getItem('auth:token') || ''}` } + }) + if (response.ok) { + closeDelete() + await fetchConfigMaps(selectedNamespace) + alert('ConfigMap deleted successfully') + } else if (response.status === 401) { navigate('/login') } else { + const data = await response.json() + alert('Failed to delete configmap: ' + (data.message || 'Unknown error')) + } + } catch (err) { + console.error('Failed to delete configmap', err) + alert('Failed to delete configmap: ' + err) + } finally { + isDeletingRef.current = false + setIsDeleting(false) + } + } + + useEffect(() => { + const stored = localStorage.getItem('selectedCluster') + if (stored) setClusterName(stored) + }, []) + + useEffect(() => { if (clusterName) fetchNamespaces() }, [clusterName]) + useEffect(() => { if (selectedNamespace && clusterName) fetchConfigMaps(selectedNamespace) }, [selectedNamespace, clusterName]) return ( -
+

ConfigMaps

Manage configuration data for applications.

+ {clusterName && ( +

Cluster: {clusterName}

+ )}
+

ConfigMap List

- +
+ + +
+
+
+ {isLoading ? ( +
Loading configmaps...
+ ) : ( @@ -53,26 +253,115 @@ export default function ConfigMaps() { - {configmaps.map((configmap) => ( - + {configmaps.map((cm) => ( + - - - - + + + + ))}
-
{configmap.name}
+
{cm.Name}
{configmap.namespace}{configmap.data}{configmap.age}{configmap.labels}{cm.Namespace}{cm.Data}{cm.Age}{cm.Labels} - - - + +
+ )}
+ + {/* View Manifest Sidebar */} + {isSidebarOpen && selectedForManifest && ( +
+
+

ConfigMap Manifest

+ +
+
+
+
Name: {selectedForManifest?.Name}
+
Namespace: {selectedForManifest?.Namespace}
+
+
+
+ {isLoadingManifest ? ( +
Loading manifest...
+ ) : ( +
+
{manifest}
+
+ )} +
+
+ +
+
+ )} + + {/* Create ConfigMap Sidebar */} + {isCreateSidebarOpen && ( +
+
+

Create ConfigMap

+ +
+
+
+

Instructions:

+

Paste your ConfigMap YAML. It will be base64-encoded and sent to the API.

+
+
+
+
+