563 lines
16 KiB
TypeScript
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
|