diff --git a/src/assets/icons/otpicon.afdesign b/src/assets/icons/otpicon.afdesign new file mode 100644 index 0000000..2081fc7 Binary files /dev/null and b/src/assets/icons/otpicon.afdesign differ diff --git a/src/assets/icons/otpicon.min.svg b/src/assets/icons/otpicon.min.svg new file mode 100644 index 0000000..70260c8 --- /dev/null +++ b/src/assets/icons/otpicon.min.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/otpicon.svg b/src/assets/icons/otpicon.svg new file mode 100644 index 0000000..58a8ce2 --- /dev/null +++ b/src/assets/icons/otpicon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/components/Dashboard/Management/Hosts/HostInfo.jsx b/src/components/Dashboard/Management/Hosts/HostInfo.jsx index 3ae2a0f..6481cf1 100644 --- a/src/components/Dashboard/Management/Hosts/HostInfo.jsx +++ b/src/components/Dashboard/Management/Hosts/HostInfo.jsx @@ -1,6 +1,6 @@ -import React from 'react' +import React, { useRef, useState } from 'react' import { useLocation } from 'react-router-dom' -import { Space, Flex, Card } from 'antd' +import { Space, Flex, Card, Modal } from 'antd' import { LoadingOutlined } from '@ant-design/icons' import loglevel from 'loglevel' import config from '../../../../config.js' @@ -19,12 +19,15 @@ import ActionHandler from '../../common/ActionHandler.jsx' import ObjectActions from '../../common/ObjectActions.jsx' import ObjectTable from '../../common/ObjectTable.jsx' import InfoCollapsePlaceholder from '../../common/InfoCollapsePlaceholder.jsx' +import HostOTP from './HostOtp.jsx' const log = loglevel.getLogger('HostInfo') log.setLevel(config.logLevel) const HostInfo = () => { const location = useLocation() + const editFormRef = useRef(null) + const actionHandlerRef = useRef(null) const hostId = new URLSearchParams(location.search).get('hostId') const [collapseState, updateCollapseState] = useCollapseState('HostInfo', { info: true, @@ -33,103 +36,115 @@ const HostInfo = () => { auditLogs: true }) + const [hostOTPOpen, setHostOTPOpen] = useState(false) + const [editFormState, setEditFormState] = useState({ + isEditing: false, + editLoading: false, + formValid: false, + locked: false, + loading: false + }) + + const actions = { + reload: () => { + editFormRef?.current.handleFetchObject() + return true + }, + hostOTP: () => { + setHostOTPOpen(true) + return true + }, + edit: () => { + editFormRef?.current.startEditing() + return false + }, + cancelEdit: () => { + editFormRef?.current.cancelEditing() + return true + }, + finishEdit: () => { + editFormRef?.current.handleUpdate() + return true + } + } + return ( - - {({ - loading, - isEditing, - startEditing, - cancelEditing, - handleUpdate, - formValid, - objectData, - editLoading, - lock, - fetchObject - }) => { - // Define actions for ActionHandler - const actions = { - reload: () => { - fetchObject() - return true - }, - edit: () => { - startEditing() - return false - }, - cancelEdit: () => { - cancelEditing() - return true - }, - finishEdit: () => { - handleUpdate() - return true - } - } + <> + + + + + + + + + + + { + actionHandlerRef.current.callAction('finishEdit') + }} + cancelEditing={() => { + actionHandlerRef.current.callAction('cancelEdit') + }} + startEditing={() => { + actionHandlerRef.current.callAction('edit') + }} + editLoading={editFormState.editLoading} + formValid={editFormState.formValid} + disabled={editFormState.lock?.locked || editFormState.loading} + loading={editFormState.editLoading} + /> + + - return ( - - {({ callAction }) => ( - + + + } + active={collapseState.info} + onToggle={(expanded) => updateCollapseState('info', expanded)} + collapseKey='info' > - - - - - - - - - - { - callAction('finishEdit') - }} - cancelEditing={() => { - callAction('cancelEdit') - }} - startEditing={() => { - callAction('edit') - }} - editLoading={editLoading} - formValid={formValid} - disabled={lock?.locked || loading} - loading={editLoading} - /> - - - -
- - } - active={collapseState.info} - onToggle={(expanded) => - updateCollapseState('info', expanded) - } - collapseKey='info' - > + { + console.log('Got edit form state change', state) + setEditFormState((prev) => ({ ...prev, ...state })) + }} + > + {({ loading, isEditing, objectData }) => { + return ( } @@ -137,49 +152,59 @@ const HostInfo = () => { type='host' objectData={objectData} /> - + ) + }} + + + - } - active={collapseState.notes} - onToggle={(expanded) => - updateCollapseState('notes', expanded) - } - collapseKey='notes' - > - - - - + } + active={collapseState.notes} + onToggle={(expanded) => updateCollapseState('notes', expanded)} + collapseKey='notes' + > + + + + - } - active={collapseState.auditLogs} - onToggle={(expanded) => - updateCollapseState('auditLogs', expanded) - } - collapseKey='auditLogs' - > - {loading ? ( - - ) : ( - - )} - - -
-
- )} -
- ) - }} -
+ } + active={collapseState.auditLogs} + onToggle={(expanded) => + updateCollapseState('auditLogs', expanded) + } + collapseKey='auditLogs' + > + {editFormState.loading ? ( + + ) : ( + + )} + + + + + + { + setHostOTPOpen(false) + }} + footer={false} + > + + + ) } diff --git a/src/components/Dashboard/Management/Hosts/HostOtp.jsx b/src/components/Dashboard/Management/Hosts/HostOtp.jsx new file mode 100644 index 0000000..5601ce9 --- /dev/null +++ b/src/components/Dashboard/Management/Hosts/HostOtp.jsx @@ -0,0 +1,138 @@ +import PropTypes from 'prop-types' +import React, { useContext, useEffect, useState, useRef } from 'react' +import { Input, Result, Typography, Flex, Progress, Button } from 'antd' +import { LoadingOutlined } from '@ant-design/icons' +import { ApiServerContext } from '../../context/ApiServerContext' +import CopyButton from '../../common/CopyButton' +import OTPIcon from '../../../Icons/OTPIcon' + +const { Text } = Typography + +const HostOTP = ({ id }) => { + const { fetchHostOTP, sendObjectAction } = useContext(ApiServerContext) + const [hostObject, setHostObject] = useState(null) + const [loading, setLoading] = useState(true) + const [timeRemaining, setTimeRemaining] = useState(0) + const [totalTime, setTotalTime] = useState(0) + const [initialized, setInitialized] = useState(false) + const intervalRef = useRef(null) + + const fetchNewOTP = () => { + setLoading(true) + setHostObject(null) // Reset to show loading + fetchHostOTP(id, (hostOTPObject) => { + setHostObject(hostOTPObject) + setLoading(false) + + if (hostOTPObject?.otpExpiresAt) { + const now = Date.now() + const expiresAt = new Date(hostOTPObject.otpExpiresAt).getTime() + const remaining = Math.max(0, expiresAt - now) + + setTimeRemaining(remaining) + setTotalTime(remaining) + } + }) + } + + const sendTestAction = () => { + console.log('Sending test action...') + sendObjectAction(id, 'host', { method: 'testMethod' }, (result) => { + console.log('Got callback', result) + }) + } + + useEffect(() => { + if (hostObject === null && initialized == false) { + setInitialized(true) + fetchNewOTP() + } + }, [id]) + + useEffect(() => { + if (hostObject && timeRemaining > 0) { + intervalRef.current = setInterval(() => { + setTimeRemaining((prev) => { + const newTime = prev - 1000 + if (newTime <= 0) { + // OTP expired, fetch a new one + fetchNewOTP() + return 0 + } + return newTime + }) + }, 1000) + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current) + } + } + } + }, [hostObject, timeRemaining]) + + // Clean up interval on unmount + useEffect(() => { + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current) + } + } + }, []) + + const progressPercent = + totalTime > 0 ? Math.max(0, (timeRemaining / totalTime) * 100) : 0 + + return ( + + + Enter the following one time passcode.} + icon={} + > + + + +
+ e.preventDefault()} // prevent typing + onKeyDown={(e) => e.preventDefault()} // prevent key input + onPaste={(e) => e.preventDefault()} // prevent pasting + /> +
+
+ {loading ? ( + + + + ) : ( + + )} +
+
+
+
+
+ ) +} + +HostOTP.propTypes = { + id: PropTypes.string.isRequired +} + +export default HostOTP diff --git a/src/components/Icons/OTPIcon.jsx b/src/components/Icons/OTPIcon.jsx new file mode 100644 index 0000000..c5731f5 --- /dev/null +++ b/src/components/Icons/OTPIcon.jsx @@ -0,0 +1,7 @@ +import React from 'react' +import Icon from '@ant-design/icons' +import { ReactComponent as CustomIconSvg } from '../../assets/icons/otpicon.min.svg' + +const OTPIcon = (props) => + +export default OTPIcon diff --git a/src/database/models/Host.js b/src/database/models/Host.js index 89097cb..16b6006 100644 --- a/src/database/models/Host.js +++ b/src/database/models/Host.js @@ -2,6 +2,7 @@ import HostIcon from '../../components/Icons/HostIcon' import InfoCircleIcon from '../../components/Icons/InfoCircleIcon' import ReloadIcon from '../../components/Icons/ReloadIcon' import EditIcon from '../../components/Icons/EditIcon' +import OTPIcon from '../../components/Icons/OTPIcon' export const Host = { name: 'host', @@ -15,7 +16,7 @@ export const Host = { default: true, row: true, icon: InfoCircleIcon, - url: (_id) => `/dashboard/production/hosts/info?hostId=${_id}` + url: (_id) => `/dashboard/management/hosts/info?hostId=${_id}` }, { @@ -23,14 +24,21 @@ export const Host = { label: 'Reload', icon: ReloadIcon, url: (_id) => - `/dashboard/production/hosts/info?hostId=${_id}&action=reload` + `/dashboard/management/hosts/info?hostId=${_id}&action=reload` + }, + { + name: 'connect', + label: 'Connect', + icon: OTPIcon, + url: (_id) => + `/dashboard/management/hosts/info?hostId=${_id}&action=hostOTP` }, { name: 'edit', label: 'Edit', row: true, icon: EditIcon, - url: (_id) => `/dashboard/production/hosts/info?hostId=${_id}&action=edit` + url: (_id) => `/dashboard/management/hosts/info?hostId=${_id}&action=edit` } ], columns: ['name', '_id', 'state', 'tags', 'connectedAt'], @@ -61,30 +69,95 @@ export const Host = { }, { name: 'state', - label: 'Status', + label: 'State', type: 'state', objectType: 'host', showName: false, readOnly: true }, { - name: 'host', - label: 'Host', - type: 'text', + name: 'active', + label: 'Active', + type: 'bool', required: true }, + { + name: 'online', + label: 'Online', + type: 'bool', + readOnly: true + }, + { + name: 'deviceInfo.os', + label: 'Operating System', + type: 'text', + required: false, + readOnly: true, + value: (objectData) => { + if ( + objectData.deviceInfo?.os?.type && + objectData.deviceInfo?.os?.release && + objectData.deviceInfo?.os?.arch + ) { + return `${objectData.deviceInfo.os.type} ${objectData.deviceInfo.os.release} (${objectData.deviceInfo.os.arch})` + } + } + }, + { + name: 'deviceInfo.os.hostname', + label: 'Hostname', + type: 'text', + required: false, + readOnly: true + }, + { + name: 'deviceInfo.cpu.model', + label: 'CPU Model', + type: 'text', + required: false, + readOnly: true + }, + { + name: 'deviceInfo.cpu', + label: 'CPU Info', + type: 'text', + required: false, + readOnly: true, + value: (objectData) => { + if ( + objectData.deviceInfo?.cpu?.cores && + objectData.deviceInfo?.cpu?.speedMHz + ) { + return `Cores: ${objectData.deviceInfo.cpu.cores}, Speed: ${objectData.deviceInfo.cpu.speedMHz} MHz` + } + } + }, + { + name: 'deviceInfo.user.username', + label: 'User', + type: 'text', + required: false, + readOnly: true + }, + { + name: 'deviceInfo.user.homedir', + label: 'User Home', + type: 'text', + required: false, + readOnly: true + }, + { + name: 'deviceInfo.process.nodeVersion', + label: 'NodeJS Version', + type: 'text', + required: false, + readOnly: true + }, { name: 'tags', label: 'Tags', type: 'tags', required: false - }, - { - name: 'operatingSystem', - label: 'Operating System', - type: 'text', - required: false, - readOnly: true } ] }