@@ -1,6 +1,6 @@
import { Alert , Button , Card , Descriptions , Input , Modal , Popconfirm , Space , Spin , Table , Tag , Tooltip , Typography , message } from 'antd'
import { Alert , Button , Card , Descriptions , Input , Popconfirm , Space , Spin , Table , Tag , Tooltip , Typography , message } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { ApartmentOutlined , CopyOutlined , DeleteOutlined , KeyOutlined } from '@ant-design/icons'
import { ApartmentOutlined , CopyOutlined , DeleteOutlined , KeyOutlined , ReloadOutlined } from '@ant-design/icons'
import { useMutation , useQuery , useQueryClient } from '@tanstack/react-query'
import { useEffect , useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -9,7 +9,9 @@ import apiClient, { isEnvelope } from '../../api/client'
import PageHeader from '../../components/PageHeader'
import { useAuthStore } from '../../stores/auth'
const { Text } = Typography
const { Text , Paragraph } = Typography
const INSTALL_ONELINER = 'curl -fsSL https://get.netcell-edgeguard.de | sudo bash'
interface HANode {
id : string
@@ -102,9 +104,6 @@ function lastSeenRelative(iso: string | null | undefined, now: number): string {
return ` ${ Math . round ( ms / 3 _600_000 ) } h `
}
// useTickingNow gibt einen `now`-Wert zurück der jede Sekunde
// re-rendert. Damit tickt das Cluster-Last-Seen-Label visuell jede
// Sekunde, statt nur alle 30s beim useQuery-Refetch.
function useTickingNow ( intervalMs = 1000 ) : number {
const [ now , setNow ] = useState ( ( ) = > Date . now ( ) )
useEffect ( ( ) = > {
@@ -114,24 +113,110 @@ function useTickingNow(intervalMs = 1000): number {
return now
}
function formatUptime ( sec : number ) : string {
if ( ! sec || sec < 0 ) return '—'
const d = Math . floor ( sec / 86400 )
const h = Math . floor ( ( sec % 86400 ) / 3600 )
const m = Math . floor ( ( sec % 3600 ) / 60 )
if ( d > 0 ) return ` ${ d } d ${ h } h `
if ( h > 0 ) return ` ${ h } h ${ m } m `
return ` ${ m } m `
}
function CertExpiry ( { days , until } : { days : number ; until : string } ) {
const { t } = useTranslation ( )
const color = days < 0 ? 'red' : days < 30 ? 'red' : days < 90 ? 'orange' : 'green'
const label = days < 0
? t ( 'cluster.certExpiredDaysAgo' , { n : - days } )
: t ( 'cluster.certDaysRemaining' , { n : days } )
return (
< Tooltip title = { new Date ( until ) . toLocaleString ( ) } >
< Tag color = { color } > { label } < / Tag >
< / Tooltip >
)
}
// Copyable code block — monospace TextArea + copy button below
function CopyCode ( { value } : { value : string } ) {
const { t } = useTranslation ( )
return (
< div >
< Input.TextArea
value = { value }
readOnly
autoSize = { { minRows : 1 , maxRows : 5 } }
style = { { fontFamily : 'monospace' , fontSize : 12 } }
/ >
< Button
icon = { < CopyOutlined / > }
size = "small"
className = "mt-8"
onClick = { ( ) = > {
void navigator . clipboard . writeText ( value )
void message . success ( t ( 'common.copied' ) )
} }
>
{ t ( 'common.copy' ) }
< / Button >
< / div >
)
}
// Numbered step row inside the "Add node" card
function StepRow ( { n , title , children } : { n : number ; title : string ; children : React.ReactNode } ) {
return (
< div style = { { display : 'flex' , gap : 16 , paddingBottom : 20 , marginBottom : 20 , borderBottom : '1px solid #f0f0f0' } } >
< div style = { {
flexShrink : 0 , width : 28 , height : 28 , borderRadius : '50%' ,
background : '#1677ff' , color : '#fff' ,
display : 'flex' , alignItems : 'center' , justifyContent : 'center' ,
fontWeight : 700 , fontSize : 13 , marginTop : 2 ,
} } >
{ n }
< / div >
< div style = { { flex : 1 , minWidth : 0 } } >
< Text strong style = { { display : 'block' , marginBottom : 8 } } > { title } < / Text >
{ children }
< / div >
< / div >
)
}
function StepRowLast ( { n , title , children } : { n : number ; title : string ; children : React.ReactNode } ) {
return (
< div style = { { display : 'flex' , gap : 16 } } >
< div style = { {
flexShrink : 0 , width : 28 , height : 28 , borderRadius : '50%' ,
background : '#52c41a' , color : '#fff' ,
display : 'flex' , alignItems : 'center' , justifyContent : 'center' ,
fontWeight : 700 , fontSize : 13 , marginTop : 2 ,
} } >
{ n }
< / div >
< div style = { { flex : 1 , minWidth : 0 } } >
< Text strong style = { { display : 'block' , marginBottom : 8 } } > { title } < / Text >
{ children }
< / div >
< / div >
)
}
export default function ClusterPage() {
const { t } = useTranslation ( )
const now = useTickingNow ( )
const isViewer = useAuthStore ( ( s ) = > s . user ? . role ) === 'viewer'
const qc = useQueryClient ( )
const { data , isLoading } = useQuery ( {
const { data , isLoading , isError , error } = useQuery ( {
queryKey : [ 'cluster' , 'status' ] ,
queryFn : async ( ) = > {
const r = await apiClient . get ( '/cluster/status' )
return isEnvelope ( r . data ) ? ( r . data . data as ClusterStatus ) : null
} ,
refetchInterval : 30_000 ,
retry : 1 ,
} )
// Phase 3.3: per-Node Load via mTLS-Aggregator. Single-node = nur die
// eigene Zeile. Multi-Node = pro Peer eine. refetchInterval bewusst
// langsamer als /cluster/status weil fan-out N× 3s Netzwerk-Roundtrips
// bedeuten kann.
const loadQuery = useQuery ( {
queryKey : [ 'cluster' , 'system-load' ] ,
queryFn : async ( ) = > {
@@ -140,6 +225,7 @@ export default function ClusterPage() {
return payload ? . nodes ? ? [ ]
} ,
refetchInterval : 60_000 ,
retry : 1 ,
} )
const certStatus = useQuery ( {
@@ -149,8 +235,9 @@ export default function ClusterPage() {
return isEnvelope ( r . data ) ? ( r . data . data as CertStatus ) : null
} ,
refetchInterval : 5 * 60 _000 ,
retry : 1 ,
} )
const qc = useQueryClient ( )
const removePeer = useMutation ( {
mutationFn : async ( id : string ) = > apiClient . delete ( ` /cluster/nodes/ ${ id } ` ) ,
onSuccess : ( ) = > {
@@ -169,31 +256,20 @@ export default function ClusterPage() {
message . success ( t ( 'cluster.certRenewedRestartHint' ) )
void certStatus . refetch ( )
} ,
onError : ( e : Error ) = > {
message . error ( t ( 'cluster.certRenewFailed' ) + ': ' + e . message )
} ,
onError : ( e : Error ) = > message . error ( t ( 'cluster.certRenewFailed' ) + ': ' + e . message ) ,
} )
const [ joinTokenOpen , setJoinTokenOpen ] = useState ( false )
const [ joinToken , setJoinToken ] = useState < JoinTokenResponse | null > ( null )
const generateToken = useMutation ( {
mutationFn : async ( ) = > {
const r = await apiClient . post ( '/cluster/join-tokens' )
return isEnvelope ( r . data ) ? ( r . data . data as JoinTokenResponse ) : null
} ,
onSuccess : ( t ) = > {
setJoinToken ( t )
setJoinTokenOpen ( true )
} ,
onError : ( ) = > {
message . error ( t ( 'cluster.joinTokenFailed' ) )
} ,
onSuccess : ( tok ) = > { setJoinToken ( tok ) } ,
onError : ( ) = > message . error ( t ( 'cluster.joinTokenFailed' ) ) ,
} )
if ( isLoading ) return < Spin / >
if ( ! data ) return null
const primaryFqdn = data . local_node ? . fqdn ? ? '<primary-fqdn>'
const primaryFqdn = data ? . local_node ? . fqdn ? ? window . location . hostname
const joinCmd = joinToken
? ` sudo edgeguard-ctl cluster-join ${ primaryFqdn } \\ \ n --token ${ joinToken . token } `
: ''
@@ -212,15 +288,19 @@ export default function ClusterPage() {
title : t ( 'cluster.col.status' ) , dataIndex : 'status' , width : 110 ,
render : ( s : HANode [ 'status' ] ) = > statusTag ( s , t ) ,
} ,
{ title : t ( 'cluster.col.role' ) , dataIndex : 'role' , width : 110 ,
render : ( v : string ) = > < Tag color = { v === 'primary' ? 'gold' : 'default' } > { v } < / Tag > } ,
{ title : t ( 'cluster.col.apiUrl' ) , dataIndex : 'api_url' , width : 240 ,
render : ( v : string ) = > < Text style = { { fontFamily : 'monospace' , fontSize : 11 } } > { v } < / Text > } ,
{
title : t ( 'cluster.col.role' ) , dataIndex : 'role' , width : 110 ,
render : ( v : string ) = > < Tag color = { v === 'primary' ? 'gold' : 'default' } > { v } < / Tag > ,
} ,
{
title : t ( 'cluster.col.version' ) , dataIndex : 'version' , width : 100 ,
render : ( v? : string | null ) = > v ? < Tag > { v } < / Tag > : < Text type = "secondary" > — < / Text > ,
} ,
{
title : t ( 'cluster.col.configHash' ) , dataIndex : 'config_hash' , width : 160 ,
render : ( v : string | null | undefined ) = > {
if ( ! v ) return < Text type = "secondary" > — < / Text >
const localHash = data . local_node ? . config_hash
const localHash = data ? . local_node ? . config_hash
const drifts = localHash && v !== localHash
return (
< Space size = { 4 } >
@@ -230,23 +310,20 @@ export default function ClusterPage() {
)
} ,
} ,
{ title : t ( 'cluster.col.version' ) , dataIndex : 'version' , width : 100 ,
render : ( v? : string | null ) = > v ? < Tag > { v } < / Tag > : < Text type = "secondary" > — < / Text > } ,
{
title : t ( 'cluster.col.lastSeen' ) , dataIndex : 'last_seen' , width : 110 ,
render : ( v : string | null | undefined , r : HANode ) = > {
const rel = lastSeenRelative ( v , now )
const tipText = v ? new Date ( v ) . toLocaleString ( ) : t ( 'cluster.col.lastSeen' )
const stale = r . status !== 'online'
return (
< Tooltip title = { tipText } >
< Tooltip title = { v ? new Date ( v ) . toLocaleString ( ) : '—' } >
< Text type = { stale ? 'danger' : 'secondary' } style = { { fontSize : 12 } } > { rel } < / Text >
< / Tooltip >
)
} ,
} ,
{
title : t ( 'common.actions' ) , key : 'actions' , width : 11 0 ,
title : t ( 'common.actions' ) , key : 'actions' , width : 12 0 ,
render : ( _ , r ) = > (
isViewer ? (
< Tooltip title = { t ( 'auth.viewerBadge' ) } >
@@ -281,30 +358,45 @@ export default function ClusterPage() {
< PageHeader
icon = { < ApartmentOutlined / > }
title = { t ( 'cluster.title' ) }
subtitle = { t ( 'cluster.intro' , { count : 1 + data . peers . length } ) }
subtitle = { t ( 'cluster.intro' , { count : 1 + ( data ? . peers . length ? ? 0 ) } ) }
extra = {
< Space >
< Tooltip title = { isViewer ? t ( 'auth.viewerBadge' ) : undefined } >
< Button
icon = { < KeyOutlined / > }
loading = { generateToken . isPending }
disabled = { isViewer }
onClick = { ( ) = > generateToken . mutate ( ) }
>
{ t ( 'cluster.generateJoinToken' ) }
< / Button >
< / Tooltip >
< Tag color = { data . mode === 'cluster' ? 'blue' : 'default' } >
{ data . mode === 'cluster' ? t ( 'cluster.modeCluster' ) : t ( 'cluster.modeSingle' ) }
< / Tag >
< Tag color = { data . health === 'ok' ? 'green' : data . health === 'degraded' ? 'orange' : 'red' } >
{ t ( ` cluster.health. ${ data . health } ` ) }
< / Tag >
{ data && (
< >
< Tag color = { data . mode === 'cluster' ? 'blue' : 'default' } >
{ data . mode === 'cluster' ? t ( 'cluster.modeCluster' ) : t ( 'cluster.modeSingle' ) }
< / Tag >
< Tag color = { data . health === 'ok' ? 'green' : data . health === 'degraded' ? 'orange' : 'red' } >
{ t ( ` cluster.health. ${ data . health } ` ) }
< / Tag >
< / >
) }
< / Space >
}
/ >
{ data . drift_found && (
{ /* ── Error banner ──────────────────────────────────────── */ }
{ isError && (
< Alert
type = "error"
showIcon
className = "mb-16"
message = { t ( 'cluster.apiUnreachable' ) }
description = { ( error as Error ) ? . message ? ? 'Unknown error' }
action = {
< Button
size = "small"
icon = { < ReloadOutlined / > }
onClick = { ( ) = > void qc . invalidateQueries ( { queryKey : [ 'cluster' ] } ) }
>
{ t ( 'common.retry' ) }
< / Button >
}
/ >
) }
{ /* ── Config drift warning ──────────────────────────────── */ }
{ data ? . drift_found && (
< Alert
type = "warning"
showIcon
@@ -315,67 +407,137 @@ export default function ClusterPage() {
/ >
) }
{ data . mode === 'single-node' && (
< Alert
type = "info"
showIcon
className = "mb-16"
message = { t ( 'cluster.singleNodeTitle' ) }
description = { t ( 'cluster.singleNodeDesc' ) }
/ >
) }
{ /* ── Add node wizard ───────────────────────────────────── */ }
< Card title = { t ( 'cluster.addNodeTitle' ) } className = "mb-16" >
< StepRow n = { 1 } title = { t ( 'cluster.step1Title' ) } >
< Paragraph type = "secondary" style = { { marginBottom : 8 } } > { t ( 'cluster.step1Desc' ) } < / Paragraph >
< CopyCode value = { INSTALL_ONELINER } / >
< / StepRow >
< Card size = "small" title = { t ( 'cluster.self Title' ) } className = "mb-16" >
{ data . local_node ? (
< Descriptions size = "small" column = { 2 } bordered >
< Descriptions.Item label = { t ( 'cluster.col.node' ) } span = { 2 } >
< Space direction = "vertical" size = { 2 } >
< Text strong > { data . local_node . fqdn } < / Text >
< Text type = "secondary" style = { { fontFamily : 'monospace' , fontSize : 11 } } >
{ data . local_node . id }
< StepRow n = { 2 } title = { t ( 'cluster.step2 Title' ) } >
< Paragraph type = "secondary" style = { { marginBottom : 8 } } > { t ( 'cluster.step2Desc' ) } < / Paragraph >
< Tooltip title = { isViewer ? t ( 'auth.viewerBadge' ) : undefined } >
< Button
type = "primary"
icon = { < KeyOutlined / > }
loading = { generateToken . isPending }
disabled = { isViewer || ( isError && ! data ) }
onClick = { ( ) = > generateToken . mutate ( ) }
>
{ t ( 'cluster.generateJoinToken' ) }
< / Button >
< / Tooltip >
< / StepRow >
< StepRow n = { 3 } title = { t ( 'cluster.step3Title' ) } >
{ joinToken ? (
< Space direction = "vertical" style = { { width : '100%' } } size = { 12 } >
< Alert
type = "warning"
showIcon
message = { t ( 'cluster.joinTokenOneShot' ) }
description = { t ( 'cluster.joinTokenOneShotDesc' , {
expires : new Date ( joinToken . expires_at ) . toLocaleString ( ) ,
} ) }
/ >
< Descriptions size = "small" bordered column = { 1 } >
< Descriptions.Item label = { t ( 'cluster.tokenLabel' ) } >
< Text style = { { fontFamily : 'monospace' , fontSize : 11 , wordBreak : 'break-all' } } >
{ joinToken . token }
< / Text >
< / Descriptions.Item >
{ joinToken . ca_fingerprint && (
< Descriptions.Item label = { t ( 'cluster.caFingerprintLabel' ) } >
< Text code style = { { fontSize : 11 } } > { joinToken . ca_fingerprint } < / Text >
< / Descriptions.Item >
) }
< / Descriptions >
< div >
< Text type = "secondary" style = { { display : 'block' , marginBottom : 6 } } >
{ t ( 'cluster.joinCmdLabel' ) }
< / Text >
< /Space >
< / Descriptions.Item >
< Descriptions.Item label = { t ( 'cluster.col.status' ) } >
{ statusTag ( data . local_node . status , t ) }
< / Descriptions.Item >
< Descriptions.Item label = { t ( 'cluster.col.role' ) } >
< Tag color = { data . local_node . role === 'primary' ? 'gold' : 'default' } >
{ data . local_node . role }
< / Tag >
< / Descriptions.Item >
< Descriptions.Item label = { t ( 'cluster.col.version' ) } >
{ data . local_node . version ? < Tag > { data . local_node . version } < / Tag > : '—' }
< / Descriptions.Item >
< Descriptions.Item label = { t ( 'cluster.col.lastSeen' ) } >
< Tooltip title = { data . local_node . last_seen ? new Date ( data . local_node . last_seen ) . toLocaleString ( ) : '—' } >
< Text type = { data . local_node . status === 'online' ? 'secondary' : 'danger' } style = { { fontSize : 12 } } >
{ lastSeenRelative ( data . local_node . last_seen , now ) }
< / Text >
< / Tooltip >
< / Descriptions.Item >
< Descriptions.Item label = { t ( 'cluster.col.mgmtIp' ) } >
< Text style = { { fontFamily : 'monospace' } } >
{ data . local_node . mgmt_ip || '—' }
< / Text >
< / Descriptions.Item >
< Descriptions.Item label = { t ( 'cluster.col.apiUrl' ) } span = { 2 } >
< Text style = { { fontFamily : 'monospace' , fontSize : 12 } } >
{ data . local_node . api_url }
< / Text >
< / Descriptions.Item >
< Descriptions.Item label = { t ( 'cluster.col.configHash' ) } span = { 2 } >
< Text code > { data . local_node . config_hash || '—' } < / Text >
< / Descriptions.Item >
< / Descriptions >
) : (
< Text type = "secondary" > { t ( 'cluster.noSelf' ) } < / Text >
) }
< CopyCode value = { joinCmd } / >
< / div >
< / Space >
) : (
< Text type = "secondary" > { t ( 'cluster.step3NoToken' ) } < / Text >
) }
< / StepRow >
< StepRowLast n = { 4 } title = { t ( 'cluster.step4Title' ) } >
< CopyCode value = "sudo systemctl restart edgeguard-api" / >
< Paragraph type = "secondary" style = { { marginTop : 8 , marginBottom : 0 } } >
{ t ( 'cluster.step4Desc' ) }
< / Paragraph >
< / StepRowLast >
< / Card >
{ ( certStatus . data ? . has_ca || certStatus . data ? . has_peer ) && (
< Card size = "small" title = { t ( 'cluster.certCardTitle' ) } className = "mb-16"
extra = { certStatus . data ? . has_ca && (
{ /* ── Dieser Knoten ─────────────────────────────────────── */ }
{ isLoading ? (
< Card size = "small" title = { t ( 'cluster.selfTitle' ) } className = "mb-16" >
< Spin / >
< / Card >
) : data ? (
< Card size = "small" title = { t ( 'cluster.selfTitle' ) } className = "mb-16" >
{ data . local_node ? (
< Descriptions size = "small" column = { 2 } bordered >
< Descriptions.Item label = { t ( 'cluster.col.node' ) } span = { 2 } >
< Space direction = "vertical" size = { 2 } >
< Text strong > { data . local_node . fqdn } < / Text >
< Text type = "secondary" style = { { fontFamily : 'monospace' , fontSize : 11 } } >
{ data . local_node . id }
< / Text >
< / Space >
< / Descriptions.Item >
< Descriptions.Item label = { t ( 'cluster.col.status' ) } >
{ statusTag ( data . local_node . status , t ) }
< / Descriptions.Item >
< Descriptions.Item label = { t ( 'cluster.col.role' ) } >
< Tag color = { data . local_node . role === 'primary' ? 'gold' : 'default' } >
{ data . local_node . role }
< / Tag >
< / Descriptions.Item >
< Descriptions.Item label = { t ( 'cluster.col.version' ) } >
{ data . local_node . version ? < Tag > { data . local_node . version } < / Tag > : '—' }
< / Descriptions.Item >
< Descriptions.Item label = { t ( 'cluster.col.lastSeen' ) } >
< Tooltip title = { data . local_node . last_seen ? new Date ( data . local_node . last_seen ) . toLocaleString ( ) : '—' } >
< Text type = "secondary" style = { { fontSize : 12 } } >
{ lastSeenRelative ( data . local_node . last_seen , now ) }
< / Text >
< / Tooltip >
< / Descriptions.Item >
< Descriptions.Item label = { t ( 'cluster.col.mgmtIp' ) } >
< Text style = { { fontFamily : 'monospace' } } >
{ data . local_node . mgmt_ip || '—' }
< / Text >
< / Descriptions.Item >
< Descriptions.Item label = { t ( 'cluster.col.apiUrl' ) } span = { 2 } >
< Text style = { { fontFamily : 'monospace' , fontSize : 12 } } >
{ data . local_node . api_url }
< / Text >
< / Descriptions.Item >
< Descriptions.Item label = { t ( 'cluster.col.configHash' ) } span = { 2 } >
< Text code > { data . local_node . config_hash || '—' } < / Text >
< / Descriptions.Item >
< / Descriptions >
) : (
< Alert
type = "warning"
showIcon
message = { t ( 'cluster.noSelf' ) }
/ >
) }
< / Card >
) : null }
{ /* ── Cluster-TLS Zertifikate ───────────────────────────── */ }
{ certStatus . data ? . has_ca && (
< Card
size = "small"
title = { t ( 'cluster.certCardTitle' ) }
className = "mb-16"
extra = {
isViewer ? (
< Tooltip title = { t ( 'auth.viewerBadge' ) } >
< Button size = "small" disabled > { t ( 'cluster.renewSelfBtn' ) } < / Button >
@@ -392,7 +554,7 @@ export default function ClusterPage() {
< / Button >
< / Popconfirm >
)
) }
}
>
< Descriptions size = "small" column = { 2 } bordered >
{ certStatus . data . ca && (
@@ -419,8 +581,13 @@ export default function ClusterPage() {
< / Card >
) }
{ data . peers . length > 0 && (
< Card size = "small" title = { t ( 'cluster.peersTitle' , { count : data.peers.length } ) } >
{ /* ── Peers ─────────────────────────────────────────────── */ }
{ data && data . peers . length > 0 && (
< Card
size = "small"
title = { t ( 'cluster.peersTitle' , { count : data.peers.length } ) }
className = "mb-16"
>
< Table
size = "small"
rowKey = "id"
@@ -431,9 +598,7 @@ export default function ClusterPage() {
< / Card >
) }
{ /* Per-Node Resources via mTLS-Aggregator (Phase 3.3). Bei
Single-Node 1 Zeile; bei Cluster N. duration_ms zeigt welcher
Peer langsam ist (Netzwerk-Latenz oder Last). */ }
{ /* ── Per-Node Resources ────────────────────────────────── */ }
< Card
size = "small"
title = { t ( 'cluster.loadTitle' ) }
@@ -457,7 +622,7 @@ export default function ClusterPage() {
) ,
} ,
{
title : t ( 'cluster.col.load' ) , key : 'load' , width : 11 0 ,
title : t ( 'cluster.col.load' ) , key : 'load' , width : 14 0 ,
render : ( _ , r ) = > r . ok && r . data
? < Text style = { { fontFamily : 'monospace' } } >
{ r . data . load_avg_1 . toFixed ( 2 ) } / { r . data . load_avg_5 . toFixed ( 2 ) } / { r . data . load_avg_15 . toFixed ( 2 ) }
@@ -465,19 +630,19 @@ export default function ClusterPage() {
: < Text type = "secondary" > — < / Text > ,
} ,
{
title : t ( 'cluster.col.mem' ) , key : 'mem' , width : 11 0,
title : t ( 'cluster.col.mem' ) , key : 'mem' , width : 9 0,
render : ( _ , r ) = > r . ok && r . data
? < Text > { r . data . mem_used_pct . toFixed ( 0 ) } % < / Text >
: < Text type = "secondary" > — < / Text > ,
} ,
{
title : t ( 'cluster.col.disk' ) , key : 'disk' , width : 11 0,
title : t ( 'cluster.col.disk' ) , key : 'disk' , width : 9 0,
render : ( _ , r ) = > r . ok && r . data
? < Text > { r . data . disk_used_pct . toFixed ( 0 ) } % < / Text >
: < Text type = "secondary" > — < / Text > ,
} ,
{
title : t ( 'cluster.col.conntrack' ) , key : 'ct' , width : 13 0 ,
title : t ( 'cluster.col.conntrack' ) , key : 'ct' , width : 14 0 ,
render : ( _ , r ) = > r . ok && r . data
? < Text style = { { fontFamily : 'monospace' , fontSize : 12 } } >
{ r . data . conntrack_count } / { r . data . conntrack_max }
@@ -485,7 +650,7 @@ export default function ClusterPage() {
: < Text type = "secondary" > — < / Text > ,
} ,
{
title : t ( 'cluster.col.uptime' ) , key : 'up' , width : 10 0,
title : t ( 'cluster.col.uptime' ) , key : 'up' , width : 9 0,
render : ( _ , r ) = > r . ok && r . data
? < Text type = "secondary" style = { { fontSize : 12 } } > { formatUptime ( r . data . uptime_sec ) } < / Text >
: < Text type = "secondary" > — < / Text > ,
@@ -499,93 +664,6 @@ export default function ClusterPage() {
] }
/ >
< / Card >
< Modal
title = { t ( 'cluster.joinTokenTitle' ) }
open = { joinTokenOpen }
onCancel = { ( ) = > setJoinTokenOpen ( false ) }
footer = { < Button onClick = { ( ) = > setJoinTokenOpen ( false ) } > { t ( 'common.close' ) } < / Button > }
width = { 720 }
>
{ joinToken ? (
< Space direction = "vertical" size = { 12 } style = { { width : '100%' } } >
< Alert
type = "warning"
showIcon
message = { t ( 'cluster.joinTokenOneShot' ) }
description = { t ( 'cluster.joinTokenOneShotDesc' , {
expires : new Date ( joinToken . expires_at ) . toLocaleString ( ) ,
} ) }
/ >
< Descriptions size = "small" column = { 1 } bordered >
< Descriptions.Item label = "Token" >
< Input.TextArea
value = { joinToken . token }
readOnly
autoSize = { { minRows : 2 , maxRows : 4 } }
style = { { fontFamily : 'monospace' , fontSize : 11 } }
/ >
< / Descriptions.Item >
< Descriptions.Item label = "CA-Fingerprint" >
< Text code > { joinToken . ca_fingerprint } < / Text >
< / Descriptions.Item >
< / Descriptions >
< div >
< Text strong > { t ( 'cluster.joinCmdLabel' ) } < / Text >
< Input.TextArea
value = { joinCmd }
readOnly
autoSize = { { minRows : 2 , maxRows : 4 } }
style = { { fontFamily : 'monospace' , fontSize : 12 , marginTop : 6 } }
/ >
< Button
icon = { < CopyOutlined / > }
size = "small"
style = { { marginTop : 6 } }
onClick = { ( ) = > {
void navigator . clipboard . writeText ( joinCmd )
message . success ( t ( 'common.copied' ) )
} }
>
{ t ( 'common.copy' ) }
< / Button >
< / div >
< / Space >
) : null }
< / Modal >
< / div >
)
}
// CertExpiry rendert "<n> Tage" mit Farbcode: rot < 30, orange < 90,
// grün sonst. Tooltip zeigt das absolute NotAfter-Datum.
function CertExpiry ( { days , until } : { days : number ; until : string } ) {
const { t } = useTranslation ( )
let color : string | undefined
if ( days < 0 ) color = '#cf1322' // already expired
else if ( days < 30 ) color = '#cf1322' // critical
else if ( days < 90 ) color = '#d4651a' // warning
else color = '#52c41a' // healthy
const label = days < 0
? t ( 'cluster.certExpiredDaysAgo' , { n : - days } )
: t ( 'cluster.certDaysRemaining' , { n : days } )
return (
< Tooltip title = { new Date ( until ) . toLocaleString ( ) } >
< Tag color = { color === '#52c41a' ? 'green' : color === '#d4651a' ? 'orange' : 'red' } >
{ label }
< / Tag >
< / Tooltip >
)
}
// formatUptime liefert "Xd Yh" oder "Xh Ym" oder "Xm" — kompakter als
// die Sekunden-Zahl.
function formatUptime ( sec : number ) : string {
if ( ! sec || sec < 0 ) return '—'
const d = Math . floor ( sec / 86400 )
const h = Math . floor ( ( sec % 86400 ) / 3600 )
const m = Math . floor ( ( sec % 3600 ) / 60 )
if ( d > 0 ) return ` ${ d } d ${ h } h `
if ( h > 0 ) return ` ${ h } h ${ m } m `
return ` ${ m } m `
}