Files
pgMultiMasterFrontend/src/App.tsx
T
george 79e10f9cad feat:
--Added delete funct
	--Added uuid funct
2026-09-09 16:04:34 +05:30

563 lines
16 KiB
TypeScript

import { useEffect, useState } from 'react'
import './App.css'
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL
const navItems = [
{ id: 'dashboard', label: 'Dashboard' },
{ id: 'nodes', label: 'Nodes' },
{ id: 'clusters', label: 'Clusters' },
] as const
type PageId = (typeof navItems)[number]['id']
type Theme = 'light' | 'dark'
type TokenStatus = 'idle' | 'loading' | 'success' | 'error'
type RegisterStatus = 'idle' | 'loading' | 'success' | 'error'
type NodesStatus = 'idle' | 'loading' | 'success' | 'error'
type RegisterNodePayload = {
node_name: string
host: string
port: number
dbname: string
user: string
password: string
}
type RegisterNodeResponse = {
status: string
message?: string
}
type NodeInterface = {
if_id: number
if_name: string
if_nodeid: number
host: string
port: number
dbname: string
user: string
uuid: string
}
type NodeInterfacesResponse = {
status: string
count: number
nodes: NodeInterface[]
message?: string
}
type DeleteNodeInterfaceResponse = {
status: string
message?: string
}
const getSavedTheme = (): Theme => {
try {
return localStorage.getItem('theme') === 'dark' ? 'dark' : 'light'
} catch {
return 'light'
}
}
const saveTheme = (theme: Theme) => {
try {
localStorage.setItem('theme', theme)
} catch {
return
}
}
const getSavedPage = (): PageId => {
try {
const savedPage = localStorage.getItem('activePage')
return navItems.some((item) => item.id === savedPage)
? (savedPage as PageId)
: 'dashboard'
} catch {
return 'dashboard'
}
}
const savePage = (page: PageId) => {
try {
localStorage.setItem('activePage', page)
} catch {
return
}
}
const getSessionToken = async () => {
const response = await fetch(`${API_BASE_URL}/get-token`, {
method: 'POST',
})
if (!response.ok) {
throw new Error('Failed to get token')
}
const data = (await response.json()) as {
access_token: string
token_type: string
}
localStorage.setItem('access_token', data.access_token)
const storedToken = localStorage.getItem('access_token')
if (!storedToken) {
throw new Error('Token was not stored')
}
console.log('Token successfully stored:', storedToken)
return storedToken
}
const registerNode = async (
payload: RegisterNodePayload,
): Promise<RegisterNodeResponse> => {
const accessToken = localStorage.getItem('access_token')
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`
}
const response = await fetch(`${API_BASE_URL}/register-node`, {
method: 'POST',
headers,
body: JSON.stringify(payload),
})
if (!response.ok) {
throw new Error('Failed to register node')
}
return response.json() as Promise<RegisterNodeResponse>
}
const getNodeInterfaces = async (): Promise<NodeInterfacesResponse> => {
const accessToken = localStorage.getItem('access_token')
const headers: Record<string, string> = {}
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`
}
const response = await fetch(`${API_BASE_URL}/spock/node-interfaces`, {
method: 'GET',
headers,
})
if (!response.ok) {
throw new Error('Failed to get node interfaces')
}
return response.json() as Promise<NodeInterfacesResponse>
}
const deleteNodeInterface = async (
node: NodeInterface,
): Promise<DeleteNodeInterfaceResponse> => {
const accessToken = localStorage.getItem('access_token')
const headers: Record<string, string> = {}
const params = new URLSearchParams({
uuid: node.uuid,
})
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`
}
const response = await fetch(
`${API_BASE_URL}/spock/del-node-interfaces?${params.toString()}`,
{
method: 'DELETE',
headers,
},
)
if (!response.ok) {
throw new Error('Failed to delete node interface')
}
return response.json() as Promise<DeleteNodeInterfaceResponse>
}
function App() {
const [isCollapsed, setIsCollapsed] = useState(false)
const [activePage, setActivePage] = useState<PageId>(getSavedPage)
const [theme, setTheme] = useState<Theme>(getSavedTheme)
const [isRegisterFormOpen, setIsRegisterFormOpen] = useState(false)
const [tokenStatus, setTokenStatus] = useState<TokenStatus>('idle')
const [tokenError, setTokenError] = useState('')
const [registerStatus, setRegisterStatus] = useState<RegisterStatus>('idle')
const [registerError, setRegisterError] = useState('')
const [nodes, setNodes] = useState<NodeInterface[]>([])
const [nodesStatus, setNodesStatus] = useState<NodesStatus>('idle')
const [nodesError, setNodesError] = useState('')
const [deletingNodeId, setDeletingNodeId] = useState<number | null>(null)
const nextTheme = theme === 'light' ? 'dark' : 'light'
const loadNodeInterfaces = async () => {
setNodesStatus('loading')
setNodesError('')
try {
const nodeInterfacesResponse = await getNodeInterfaces()
if (nodeInterfacesResponse.status !== 'success') {
throw new Error(
nodeInterfacesResponse.message ?? 'Failed to get node interfaces',
)
}
setNodes(nodeInterfacesResponse.nodes)
setNodesStatus('success')
} catch (error) {
setNodesStatus('error')
setNodesError(
error instanceof Error
? error.message
: 'Failed to get node interfaces',
)
}
}
useEffect(() => {
if (activePage === 'nodes') {
void loadNodeInterfaces()
}
}, [activePage])
const selectPage = (page: PageId) => {
setActivePage(page)
savePage(page)
}
const toggleTheme = () => {
setTheme((currentTheme) => {
const updatedTheme = currentTheme === 'light' ? 'dark' : 'light'
saveTheme(updatedTheme)
return updatedTheme
})
}
const handleTempButtonClick = async () => {
setTokenStatus('loading')
setTokenError('')
try {
await getSessionToken()
setTokenStatus('success')
await loadNodeInterfaces()
} catch (error) {
setTokenStatus('error')
setTokenError(
error instanceof Error ? error.message : 'Failed to get token',
)
}
}
const handleRegisterNode = async (
event: React.FormEvent<HTMLFormElement>,
) => {
event.preventDefault()
const form = event.currentTarget
setRegisterStatus('loading')
setRegisterError('')
const formData = new FormData(form)
const payload: RegisterNodePayload = {
node_name: String(formData.get('node_name') ?? ''),
host: String(formData.get('host') ?? ''),
port: Number(formData.get('port')),
dbname: String(formData.get('dbname') ?? ''),
user: String(formData.get('user') ?? ''),
password: String(formData.get('password') ?? ''),
}
try {
const registerResponse = await registerNode(payload)
console.log('Node registration response:', registerResponse)
if (registerResponse.status !== 'success') {
throw new Error(registerResponse.message ?? 'Failed to register node')
}
setRegisterStatus('success')
form.reset()
setIsRegisterFormOpen(false)
window.location.reload()
} catch (error) {
setRegisterStatus('error')
setRegisterError(
error instanceof Error ? error.message : 'Failed to register node',
)
}
}
const handleDeleteNodeClick = async (node: NodeInterface) => {
setDeletingNodeId(node.if_id)
setNodesError('')
try {
const deleteResponse = await deleteNodeInterface(node)
if (deleteResponse.status !== 'success') {
throw new Error(deleteResponse.message ?? 'Failed to delete node')
}
await loadNodeInterfaces()
} catch (error) {
setNodesStatus('error')
setNodesError(
error instanceof Error ? error.message : 'Failed to delete node',
)
} finally {
setDeletingNodeId(null)
}
}
return (
<div
className={`app-shell theme-${theme} ${isCollapsed ? 'nav-collapsed' : ''}`}
>
<aside className="side-nav" aria-label="Primary navigation">
<div className="nav-header">
<span className="nav-title">PG Multi Master</span>
<button
type="button"
className="nav-toggle"
onClick={() => setIsCollapsed((collapsed) => !collapsed)}
aria-label={isCollapsed ? 'Expand navigation' : 'Collapse navigation'}
aria-expanded={!isCollapsed}
>
<span aria-hidden="true">{isCollapsed ? '>' : '<'}</span>
</button>
</div>
<nav className="nav-list">
{navItems.map((item) => (
<button
key={item.id}
type="button"
className={`nav-link ${activePage === item.id ? 'active' : ''}`}
title={isCollapsed ? item.label : undefined}
onClick={() => selectPage(item.id)}
aria-current={activePage === item.id ? 'page' : undefined}
>
<span className="nav-icon" aria-hidden="true">
{item.label.charAt(0)}
</span>
<span className="nav-label">{item.label}</span>
</button>
))}
</nav>
<button
type="button"
className="theme-toggle"
onClick={toggleTheme}
title={isCollapsed ? `Switch to ${nextTheme} theme` : undefined}
aria-label={`Switch to ${nextTheme} theme`}
>
<span className="theme-icon" aria-hidden="true">
{theme === 'light' ? 'D' : 'L'}
</span>
<span className="theme-label">
{theme === 'light' ? 'Dark mode' : 'Light mode'}
</span>
</button>
</aside>
<main className="main-content">
{activePage === 'dashboard' && (
<section className="content-panel" id="dashboard">
<p className="eyebrow">Dashboard</p>
<h1>Welcome back</h1>
<p>
Your main workspace is ready. The left navigation can collapse
now, and more sections can be added to the nav list as the app
grows.
</p>
</section>
)}
{activePage === 'nodes' && (
<section className="content-panel nodes-page" id="nodes">
<div className="page-header">
<h1>Nodes</h1>
<div className="page-actions">
<button
type="button"
className="primary-button"
onClick={() => {
setIsRegisterFormOpen(true)
setRegisterStatus('idle')
setRegisterError('')
}}
>
Register Nodes
</button>
<button
type="button"
className="secondary-button"
onClick={handleTempButtonClick}
disabled={tokenStatus === 'loading'}
>
{tokenStatus === 'loading' ? 'Getting Token' : 'Temp Button'}
</button>
</div>
</div>
{tokenStatus === 'success' && (
<p className="status-message success">Token successfully stored.</p>
)}
{tokenStatus === 'error' && (
<p className="status-message error">{tokenError}</p>
)}
{registerStatus === 'success' && (
<p className="status-message success">Node registered successfully.</p>
)}
{registerStatus === 'error' && (
<p className="status-message error">{registerError}</p>
)}
{nodesStatus === 'error' && (
<p className="status-message error">{nodesError}</p>
)}
{isRegisterFormOpen ? (
<form className="register-form" onSubmit={handleRegisterNode}>
<div className="form-grid">
<label className="form-field">
<span>Node Name</span>
<input name="node_name" type="text" required />
</label>
<label className="form-field">
<span>Host</span>
<input
name="host"
type="text"
inputMode="decimal"
pattern="^((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])$"
placeholder="192.168.1.10"
required
/>
</label>
<label className="form-field">
<span>Port</span>
<input name="port" type="number" min="1" max="65535" required />
</label>
<label className="form-field">
<span>Database Name</span>
<input name="dbname" type="text" required />
</label>
<label className="form-field">
<span>User</span>
<input name="user" type="text" required />
</label>
<label className="form-field">
<span>Password</span>
<input name="password" type="password" required />
</label>
</div>
<div className="form-actions">
<button
type="button"
className="secondary-button"
onClick={() => setIsRegisterFormOpen(false)}
>
Cancel
</button>
<button
type="submit"
className="primary-button"
disabled={registerStatus === 'loading'}
>
{registerStatus === 'loading' ? 'Registering' : 'Register'}
</button>
</div>
</form>
) : null}
{!isRegisterFormOpen && (
<div className="nodes-table-wrap">
{nodesStatus === 'loading' ? (
<p className="table-state">Loading nodes...</p>
) : nodes.length > 0 ? (
<table className="nodes-table">
<thead>
<tr>
<th>Node ID</th>
<th>Node Name</th>
<th>Host</th>
<th>Port</th>
<th>DB Name</th>
<th>DB Username</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{nodes.map((node) => (
<tr key={node.if_id}>
<td>{node.if_nodeid}</td>
<td>{node.if_name}</td>
<td>{node.host}</td>
<td>{node.port}</td>
<td>{node.dbname}</td>
<td>{node.user}</td>
<td>
<button
type="button"
className="danger-button"
disabled={deletingNodeId === node.if_id}
onClick={() => handleDeleteNodeClick(node)}
>
{deletingNodeId === node.if_id
? 'Deleting'
: 'Delete'}
</button>
</td>
</tr>
))}
</tbody>
</table>
) : (
<p className="table-state">No nodes found.</p>
)}
</div>
)}
</section>
)}
{activePage === 'clusters' && (
<section className="content-panel" id="clusters">
<h1>Clusters</h1>
</section>
)}
</main>
</div>
)
}
export default App