Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/server-admin-ui/src/components/Sidebar/Sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,10 @@ const mapStateToProps = (state) => {
name: 'Devices',
url: '/security/devices'
})
security.children.push({
name: 'Active Clients',
url: '/security/clients'
})
}
if (
state.loginStatus.allowNewUserRegistration ||
Expand Down
5 changes: 5 additions & 0 deletions packages/server-admin-ui/src/containers/Full/Full.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import Login from '../../views/security/Login'
import SecuritySettings from '../../views/security/Settings'
import Users from '../../views/security/Users'
import Devices from '../../views/security/Devices'
import ActiveClients from '../../views/security/ActiveClients'
import Register from '../../views/security/Register'
import AccessRequests from '../../views/security/AccessRequests'
import ProvidersConfiguration from '../../views/ServerConfig/ProvidersConfiguration'
Expand Down Expand Up @@ -115,6 +116,10 @@ class Full extends Component {
path="/security/devices"
component={loginOrOriginal(Devices)}
/>
<Route
path="/security/clients"
component={loginOrOriginal(ActiveClients)}
/>
<Route
path="/security/access/requests"
component={loginOrOriginal(AccessRequests)}
Expand Down
198 changes: 198 additions & 0 deletions packages/server-admin-ui/src/views/security/ActiveClients.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import React, { Component } from 'react'
import { connect } from 'react-redux'
import {
Card,
CardHeader,
CardBody,
Table,
Badge,
} from 'reactstrap'
import EnableSecurity from './EnableSecurity'

class ActiveClients extends Component {
constructor(props) {
super(props)
this.state = {
activeClients: [],
loading: true,
error: null,
}

this.fetchActiveClients = this.fetchActiveClients.bind(this)
this.refreshData = this.refreshData.bind(this)
}

componentDidMount() {
if (this.props.loginStatus.authenticationRequired) {
this.fetchActiveClients()
// Refresh every 5 seconds
this.interval = setInterval(this.fetchActiveClients, 5000)
}
}

componentWillUnmount() {
if (this.interval) {
clearInterval(this.interval)
}
}

fetchActiveClients() {
fetch(`${window.serverRoutesPrefix}/security/devices/active`, {
credentials: 'include',
})
.then((response) => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
return response.json()
})
.then((data) => {
this.setState({
activeClients: data || [],
loading: false,
error: null,
})
})
.catch((error) => {
console.error('Error fetching active clients:', error)
this.setState({
loading: false,
error: error.message
})
})
}

refreshData() {
this.setState({ loading: true })
this.fetchActiveClients()
}

formatConnectedTime(connectedAt) {
if (!connectedAt) return 'Unknown'
const now = new Date()
const connected = new Date(connectedAt)
const diffMs = now - connected
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMins / 60)
const diffDays = Math.floor(diffHours / 24)

if (diffDays > 0) return `${diffDays}d ${diffHours % 24}h ago`
if (diffHours > 0) return `${diffHours}h ${diffMins % 60}m ago`
if (diffMins > 0) return `${diffMins}m ago`
return 'Just now'
}

render() {
if (!this.props.loginStatus.authenticationRequired) {
return <EnableSecurity />
}

const { activeClients, loading, error } = this.state

return (
<div className="animated fadeIn">
<Card>
<CardHeader>
<i className="fa fa-wifi"></i> Active WebSocket Clients
<div className="card-header-actions">
<button
className="btn btn-sm btn-outline-primary"
onClick={this.refreshData}
disabled={loading}
>
<i className="fa fa-refresh"></i> Refresh
</button>
</div>
</CardHeader>
<CardBody>
{error && (
<div className="alert alert-danger" role="alert">
Error loading active clients: {error}
</div>
)}

{loading && (
<div className="text-center">
<i className="fa fa-spinner fa-spin"></i> Loading...
</div>
)}

{!loading && !error && (
<>
<div className="mb-3">
<Badge color="info" className="mr-2">
{activeClients.length} active client{activeClients.length !== 1 ? 's' : ''}
</Badge>
<small className="text-muted">
Updates automatically every 5 seconds
</small>
</div>

{activeClients.length === 0 ? (
<div className="text-center text-muted">
<i className="fa fa-plug"></i>
<p>No active WebSocket clients connected</p>
</div>
) : (
<Table hover responsive striped size="sm">
<thead>
<tr>
<th>Device Name</th>
<th>Client ID</th>
<th>Remote Address</th>
<th>Connected</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{activeClients.map((client) => (
<tr key={client.clientId}>
<td>
<strong>
{client.description !== client.clientId
? client.description
: <span className="text-muted">Unnamed Client</span>
}
</strong>
{client.userAgent && client.description !== client.clientId && (
<br />
<small className="text-muted" title={client.userAgent}>
{client.userAgent.length > 50 ? client.userAgent.substring(0, 50) + '...' : client.userAgent}
</small>
)}
</td>
<td>
<code className="text-small">{client.clientId}</code>
</td>
<td>
<span className="text-muted">{client.remoteAddress}</span>
</td>
<td>
{this.formatConnectedTime(client.connectedAt)}
</td>
<td>
<Badge color="success">
<i className="fa fa-circle"></i> Active
</Badge>
</td>
</tr>
))}
</tbody>
</Table>
)}
</>
)}
</CardBody>
</Card>
</div>
)
}
}

function mapStateToProps(state) {
return {
loginStatus: state.loginStatus,
}
}

export default connect(mapStateToProps)(ActiveClients)
17 changes: 17 additions & 0 deletions src/interfaces/ws.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,23 @@ module.exports = function (app) {
return count
}

api.getActiveClients = function () {
const clients = []
primuses.forEach((primus) =>
primus.forEach((spark) => {
clients.push({
id: spark.id,
skPrincipal: spark.request.skPrincipal,
remoteAddress: spark.request.headers['x-forwarded-for'] ||
spark.request.connection.remoteAddress,
userAgent: spark.request.headers['user-agent'],
connectedAt: spark.request.connectedAt || new Date().toISOString()
})
})
)
return clients
}

api.canHandlePut = function (path, source) {
const sources = pathSources[path]
return sources && (!source || sources[source])
Expand Down
62 changes: 62 additions & 0 deletions src/serverroutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,68 @@ module.exports = function (
}
)

app.get(
`${SERVERROUTESPREFIX}/security/devices/active`,
(req: Request, res: Response) => {
if (checkAllowConfigure(req, res)) {
const activeClients: any[] = []
const devices = app.securityStrategy.getDevices(getSecurityConfig(app))

// Get active WebSocket clients from the WS interface
const anyApp = app as any
if (anyApp.interfaces && anyApp.interfaces.ws) {
anyApp.interfaces.ws.getActiveClients().forEach((client: any) => {
const clientId = client.skPrincipal?.identifier
if (clientId) {
// Find matching device info
const device = devices.find((d: any) => d.clientId === clientId)

// Build user-friendly display name with priority:
// 1. Device description from registry
// 2. Principal name from authentication
// 3. User agent (shortened)
// 4. Fall back to client ID
let displayName = device?.description

if (!displayName && client.skPrincipal?.name) {
displayName = client.skPrincipal.name
}

if (!displayName && client.userAgent) {
// Extract meaningful parts from user agent
const ua = client.userAgent
if (ua.includes('SensESP')) {
displayName = 'SensESP Device'
} else if (ua.includes('SignalK')) {
displayName = 'SignalK Client'
} else if (ua.includes('OpenCPN')) {
displayName = 'OpenCPN'
} else if (ua.includes('Chrome') || ua.includes('Firefox') || ua.includes('Safari')) {
displayName = 'Web Browser'
} else {
// Take first meaningful part of user agent
const parts = ua.split(/[\s\/\(]/)
displayName = parts[0] || 'Unknown Client'
}
}

activeClients.push({
clientId,
description: displayName || clientId,
remoteAddress: client.remoteAddress,
userAgent: client.userAgent,
connectedAt: client.connectedAt,
isActive: true
})
}
})
}

res.json(activeClients)
}
}
)

app.get(
`${SERVERROUTESPREFIX}/security/users`,
(req: Request, res: Response) => {
Expand Down
Loading
Loading