diff --git a/src/features/admin/AdminSection.jsx b/src/features/admin/AdminSection.jsx index d05dfc7..1221fc9 100644 --- a/src/features/admin/AdminSection.jsx +++ b/src/features/admin/AdminSection.jsx @@ -10,11 +10,13 @@ import { Globe, Info, MessageSquare, Sliders, Terminal } from "lucide-react"; import ServerDetailsModal from '../servers/ServerDetailsModal'; +import ServerCard from '../servers/ServerCard'; import NodeTerminalModal from './NodeTerminalModal'; import TicketSystem from '../support/TicketSystem'; import { colors } from '../../theme/colors'; import branding from '../../branding'; import { API_BASE_URL } from '../../config/api'; +import { usePanelCurrency } from '../../hooks/usePanelCurrency'; import { StatTile, ActionButton, @@ -26,6 +28,7 @@ import { } from '../../components/DashboardComponents'; const AdminSection = ({ user, activeTab: parentActiveTab, showAlert }) => { + const { calculateCurrencyValue, getCurrencySymbol } = usePanelCurrency(); const [users, setUsers] = useState([]); const [isLoading, setIsLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(''); @@ -662,6 +665,26 @@ const AdminSection = ({ user, activeTab: parentActiveTab, showAlert }) => { setSelectedServerForManagement(server); }; + const handleAdminServerAction = async (serverId, action) => { + try { + const token = localStorage.getItem('anchorhost_token'); + const response = await fetch(`${API_BASE_URL}/api/admin/servers/${encodeURIComponent(serverId)}/${action}`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }); + const data = await response.json(); + if (!response.ok || !data.success) { + throw new Error(data.error || `Could not ${action} this server.`); + } + + const actionMessage = action === 'start' ? 'The server is starting.' : 'The server is stopping.'; + showAlert('Server action sent', data.message || actionMessage, 'success'); + fetchAllServers(); + } catch (error) { + showAlert('Server action failed', error.message || 'Check the server status and try again.', 'error'); + } + }; + const filteredUsers = users.filter(u => (u.username && u.username.toLowerCase().includes(searchQuery.toLowerCase())) || (u.email && u.email.toLowerCase().includes(searchQuery.toLowerCase())) || @@ -848,60 +871,18 @@ const AdminSection = ({ user, activeTab: parentActiveTab, showAlert }) => { ) : (
{allServers.map(server => ( -
-
-
-
-
- {server.owner ? server.owner.substring(0, 2).toUpperCase() : '??'} -
-

{server.owner}

-
-
{server.name}
-

{server.image}

-
-
- - {server.status} - - handleManageServer(server)} - className="!p-2 !min-h-0 !rounded-lg opacity-0 group-hover:opacity-100 transition-opacity" - title="Detailed Management" - > - - -
-
- -
-
- - {server.cpu} -
-
- - {server.ram} -
-
- - {server.storage} -
-
- -
- ID: {server.id.substring(0, 8)}... - {new Date(server.created_at).toLocaleDateString()} -
-
+ ))}
)} diff --git a/src/features/admin/NodeTerminalModal.jsx b/src/features/admin/NodeTerminalModal.jsx index eb829de..5425bbc 100644 --- a/src/features/admin/NodeTerminalModal.jsx +++ b/src/features/admin/NodeTerminalModal.jsx @@ -1,5 +1,4 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; -import branding from '../../branding'; import { createPortal } from 'react-dom'; import { Terminal as TerminalIcon, @@ -12,13 +11,13 @@ import { Trash2, Copy, Check, - Server, - Monitor } from "lucide-react"; import { colors } from '../../theme/colors'; import useWebSocket from '../../hooks/useWebSocket'; import { WS_BASE_URL } from '../../config/api'; +const MAX_TERMINAL_OUTPUT_CHARS = 200_000; + const NodeTerminalModal = ({ node, onClose }) => { const [terminalOutput, setTerminalOutput] = useState(''); const [isConnected, setIsConnected] = useState(false); @@ -27,6 +26,10 @@ const NodeTerminalModal = ({ node, onClose }) => { const [copied, setCopied] = useState(false); const outputRef = useRef(null); const token = localStorage.getItem('anchorhost_token'); + const appendTerminalOutput = useCallback((text) => { + if (!text) return; + setTerminalOutput((previous) => `${previous}${text}`.slice(-MAX_TERMINAL_OUTPUT_CHARS)); + }, []); const { sendMessage, @@ -58,12 +61,12 @@ const NodeTerminalModal = ({ node, onClose }) => { const connectTerminal = useCallback(() => { if (!node || !sendMessage) return; setIsLoading(true); - setTerminalOutput(prev => prev + `\r\n\x1b[36m[${branding.name}]\x1b[0m Requesting shell session on node \x1b[1m${node.name}\x1b[0m...\r\n`); + appendTerminalOutput(`\r\nConnecting to ${node.name}...\r\n`); sendMessage({ type: 'node_terminal_connect', nodeName: node.name }); - }, [node, sendMessage]); + }, [appendTerminalOutput, node, sendMessage]); const disconnectTerminal = useCallback(() => { if (!node || !sendMessage) return; @@ -91,14 +94,19 @@ const NodeTerminalModal = ({ node, onClose }) => { if (data.type === 'node_terminal_output') { if (!isConnected) setIsConnected(true); setIsLoading(false); - setTerminalOutput(prev => prev + cleanOutput(data.output || '')); - } else if (data.type === 'error' && data.nodeName === node.name) { - setTerminalOutput(prev => prev + `\r\n\x1b[31m[Error] ${data.error}\x1b[0m\r\n`); + appendTerminalOutput(cleanOutput(data.output || '')); + } else if (data.type === 'node_terminal_error') { + setIsConnected(false); setIsLoading(false); + appendTerminalOutput(cleanOutput(data.output || '\r\nUnable to open a host login session.\r\n')); + } else if (data.type === 'node_terminal_disconnected') { + setIsConnected(false); + setIsLoading(false); + appendTerminalOutput(cleanOutput(data.output || '\r\nHost session closed.\r\n')); } } } - }, [lastJsonMessage, node.name, isConnected, cleanOutput]); + }, [lastJsonMessage, node.name, isConnected, cleanOutput, appendTerminalOutput]); const handleTerminalInput = (input) => { if (!isConnected || !sendMessage) return; @@ -116,7 +124,10 @@ const NodeTerminalModal = ({ node, onClose }) => { handleTerminalInput('\r'); } else if (e.key === 'Backspace') { e.preventDefault(); - handleTerminalInput('\b'); + handleTerminalInput('\x7f'); + } else if (e.key === 'Delete') { + e.preventDefault(); + handleTerminalInput('\x1b[3~'); } else if (e.key === 'Tab') { e.preventDefault(); handleTerminalInput('\t'); @@ -157,6 +168,15 @@ const NodeTerminalModal = ({ node, onClose }) => { } }; + const handlePaste = (e) => { + if (!isConnected) return; + const pastedText = e.clipboardData.getData('text/plain'); + if (pastedText) { + e.preventDefault(); + handleTerminalInput(pastedText); + } + }; + const ansiToStyle = { 0: { color: 'inherit', fontWeight: 'normal', textDecoration: 'none' }, 1: { fontWeight: 'bold' }, @@ -210,124 +230,98 @@ const NodeTerminalModal = ({ node, onClose }) => { }; const modalContent = ( -
+
- {/* Header */} -
-
-
- -
-
-
-

- Host terminal · {node.name} -

- - {node.role || 'Node'} - -
-
-
-
- - {isConnected ? 'Connected' : isLoading ? 'Connecting...' : 'Disconnected'} - -
- {node.os?.image && ( - - - {node.os.image} - - )} - {node.specs?.cpu && ( - - - {node.specs.cpu} / {node.specs.ram} - - )} -
-
+
+
+
-
+
{!isConnected ? ( ) : ( )}
-
+
{/* Terminal Screen */}
outputRef.current?.focus()} onKeyDown={handleKeyDown} - aria-label={`Interactive root terminal for node ${node.name}. Click here and type commands.`} + onPaste={handlePaste} + aria-label={`Host login terminal for ${node.name}`} role="textbox" aria-multiline="true" aria-readonly="false" - className="flex-1 p-4 md:p-6 font-mono text-xs md:text-[13px] overflow-y-auto custom-scrollbar bg-black text-gray-200 cursor-text select-none whitespace-pre-wrap break-all leading-relaxed outline-none focus-visible:ring-2 focus-visible:ring-primary" + className="min-h-0 flex-1 overflow-auto bg-black p-3 font-mono text-xs leading-5 text-gray-200 outline-none focus-visible:ring-2 focus-visible:ring-primary sm:p-4 sm:text-[13px] sm:leading-6" tabIndex={0} autoFocus > {terminalOutput ? parseAnsi(terminalOutput) : ( -
- Initializing interactive shell session to node {node.name}... +
+ {isLoading ? `Connecting to ${node.name}…` : 'Waiting for the host login prompt…'}
)} {isConnected && ( @@ -335,18 +329,12 @@ const NodeTerminalModal = ({ node, onClose }) => { )}
- {/* Footer info banner */} -
-
- root@{node.name}:~# - | Privileged host shell -
-
- Ctrl+C: Cancel - Ctrl+L: Clear - Ctrl+D: Exit -
-
+
+ Backspace erase + Tab complete + Ctrl+C interrupt + Ctrl+D logout +
); diff --git a/src/features/servers/ServerCard.jsx b/src/features/servers/ServerCard.jsx index 2433299..4cf5f88 100644 --- a/src/features/servers/ServerCard.jsx +++ b/src/features/servers/ServerCard.jsx @@ -11,6 +11,7 @@ import { Play, Server, Square, + Settings, Terminal, } from 'lucide-react'; import { ActionButton, getServerStatusInfo } from '../../components/DashboardComponents.jsx'; @@ -47,6 +48,8 @@ export default function ServerCard({ onConvertDemo, onOpenConsole, onServerAction, + owner, + isAdminView = false, }) { const statusInfo = getServerStatusInfo(server.status); const { icon: ServerIcon, color: serverColor } = serverTypeIcons[server.type] || serverTypeIcons.vps; @@ -95,6 +98,7 @@ export default function ServerCard({

{server.name}

{isDemo && DEMO}
+ {owner &&

Owner: {owner}

}

{isDatabase ? `${dbType} db` : `${server.type} node`}

{isDemo && timeRemaining && (
@@ -152,10 +156,10 @@ export default function ServerCard({ ) : ( <> onOpenConsole(server)} className="flex-1 !min-h-0 !py-3"> - - Console + {isAdminView ? : } + {isAdminView ? 'Manage' : 'Console'} - {isDemo && ( + {isDemo && !isAdminView && ( onConvertDemo(server.id)} diff --git a/src/index.css b/src/index.css index be4d1e1..fdd6555 100644 --- a/src/index.css +++ b/src/index.css @@ -478,6 +478,7 @@ body > div:not(#root) :is(button, a, input, textarea, select):focus-visible { ou .ah-server-card-heading { min-width: 0; flex: 1; } .ah-server-card-heading > .flex { min-width: 0; align-items: center; } .ah-server-name { min-width: 0; max-width: 100%; overflow: hidden; color: var(--ah-emphasis); font-size: 17px; font-weight: 800; line-height: 1.25; letter-spacing: -.02em; text-overflow: ellipsis; white-space: nowrap; } +.ah-server-owner { overflow: hidden; margin: 4px 0 0; color: var(--ah-text); font-size: 12px; font-weight: 650; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; } .ah-server-demo-tag { flex: 0 0 auto; padding: 2px 6px; border: 1px solid var(--ah-warning); background: var(--ah-background); color: var(--ah-warning); font-size: 10px; font-weight: 800; letter-spacing: .04em; } .ah-server-type-label { overflow: hidden; margin: 5px 0 0; color: var(--ah-muted); font-size: 11px; font-weight: 700; line-height: 1.3; text-overflow: ellipsis; text-transform: uppercase; letter-spacing: .035em; } .ah-server-demo-time { display: inline-flex; align-items: center; gap: 6px; width: fit-content; margin-top: 8px; padding: 4px 7px; border: 1px solid var(--ah-warning); background: var(--ah-background); }