fix(admin): add authenticated node login and shared server cards
Build and publish Docker image / build-and-publish (push) Successful in 12s
Build and publish Docker image / build-and-publish (push) Successful in 12s
This commit is contained in:
1 parent
b7b36b1355
commit
7ac8ab189e
4 files changed
+111
-137
No files matched your search
@@ -10,11 +10,13 @@ import {
|
|||||||
Globe, Info, MessageSquare, Sliders, Terminal
|
Globe, Info, MessageSquare, Sliders, Terminal
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import ServerDetailsModal from '../servers/ServerDetailsModal';
|
import ServerDetailsModal from '../servers/ServerDetailsModal';
|
||||||
|
import ServerCard from '../servers/ServerCard';
|
||||||
import NodeTerminalModal from './NodeTerminalModal';
|
import NodeTerminalModal from './NodeTerminalModal';
|
||||||
import TicketSystem from '../support/TicketSystem';
|
import TicketSystem from '../support/TicketSystem';
|
||||||
import { colors } from '../../theme/colors';
|
import { colors } from '../../theme/colors';
|
||||||
import branding from '../../branding';
|
import branding from '../../branding';
|
||||||
import { API_BASE_URL } from '../../config/api';
|
import { API_BASE_URL } from '../../config/api';
|
||||||
|
import { usePanelCurrency } from '../../hooks/usePanelCurrency';
|
||||||
import {
|
import {
|
||||||
StatTile,
|
StatTile,
|
||||||
ActionButton,
|
ActionButton,
|
||||||
@@ -26,6 +28,7 @@ import {
|
|||||||
} from '../../components/DashboardComponents';
|
} from '../../components/DashboardComponents';
|
||||||
|
|
||||||
const AdminSection = ({ user, activeTab: parentActiveTab, showAlert }) => {
|
const AdminSection = ({ user, activeTab: parentActiveTab, showAlert }) => {
|
||||||
|
const { calculateCurrencyValue, getCurrencySymbol } = usePanelCurrency();
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
@@ -662,6 +665,26 @@ const AdminSection = ({ user, activeTab: parentActiveTab, showAlert }) => {
|
|||||||
setSelectedServerForManagement(server);
|
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 =>
|
const filteredUsers = users.filter(u =>
|
||||||
(u.username && u.username.toLowerCase().includes(searchQuery.toLowerCase())) ||
|
(u.username && u.username.toLowerCase().includes(searchQuery.toLowerCase())) ||
|
||||||
(u.email && u.email.toLowerCase().includes(searchQuery.toLowerCase())) ||
|
(u.email && u.email.toLowerCase().includes(searchQuery.toLowerCase())) ||
|
||||||
@@ -848,60 +871,18 @@ const AdminSection = ({ user, activeTab: parentActiveTab, showAlert }) => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||||
{allServers.map(server => (
|
{allServers.map(server => (
|
||||||
<div
|
<ServerCard
|
||||||
key={server.id}
|
key={server.id}
|
||||||
className="group relative border-2 border-dash-border bg-dash-surfaceElevated p-5 shadow-[5px_5px_0_0_rgba(0,0,0,0.65)] transition-colors hover:border-primary"
|
server={server}
|
||||||
>
|
owner={server.owner}
|
||||||
<div className="flex justify-between items-start mb-4">
|
isAdminView
|
||||||
<div className="min-w-0">
|
pricing={pricing}
|
||||||
<div className="flex items-center space-x-2 mb-2">
|
userRole="admin"
|
||||||
<div className="w-6 h-6 rounded-lg bg-primary/10 border border-primary/20 flex items-center justify-center text-[10px] font-bold text-primary shadow-sm">
|
calculateCurrencyValue={calculateCurrencyValue}
|
||||||
{server.owner ? server.owner.substring(0, 2).toUpperCase() : '??'}
|
getCurrencySymbol={getCurrencySymbol}
|
||||||
</div>
|
onOpenConsole={handleManageServer}
|
||||||
<p className="text-xs font-bold text-dash-textMuted group-hover:text-primary transition-colors truncate">{server.owner}</p>
|
onServerAction={handleAdminServerAction}
|
||||||
</div>
|
/>
|
||||||
<h5 className="font-bold text-sm text-dash-text truncate pr-4">{server.name}</h5>
|
|
||||||
<p className="text-[10px] text-dash-textMuted mt-1 font-medium">{server.image}</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-end gap-3">
|
|
||||||
<span className={`px-2 py-0.5 rounded-lg text-[10px] font-bold uppercase tracking-wider border ${
|
|
||||||
server.status === 'Running'
|
|
||||||
? 'bg-brightGreen/10 border-brightGreen/20 text-brightGreen'
|
|
||||||
: 'bg-brightRed/10 border-brightRed/20 text-brightRed'
|
|
||||||
}`}>
|
|
||||||
{server.status}
|
|
||||||
</span>
|
|
||||||
<ActionButton
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => handleManageServer(server)}
|
|
||||||
className="!p-2 !min-h-0 !rounded-lg opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
title="Detailed Management"
|
|
||||||
>
|
|
||||||
<ExternalLink className="w-4 h-4 text-primary" />
|
|
||||||
</ActionButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-2 mt-4 pt-4 border-t border-dash-border">
|
|
||||||
<div className="flex flex-col items-center p-2 rounded-xl bg-black/20 border border-white/[0.03]">
|
|
||||||
<Cpu className="w-3.5 h-3.5 mb-1.5 text-primary opacity-80" />
|
|
||||||
<span className="text-[10px] font-bold text-dash-text">{server.cpu}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center p-2 rounded-xl bg-black/20 border border-white/[0.03]">
|
|
||||||
<MemoryStick className="w-3.5 h-3.5 mb-1.5 text-brightGreen opacity-80" />
|
|
||||||
<span className="text-[10px] font-bold text-dash-text">{server.ram}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center p-2 rounded-xl bg-black/20 border border-white/[0.03]">
|
|
||||||
<HardDriveIcon className="w-3.5 h-3.5 mb-1.5 text-brightYellow opacity-80" />
|
|
||||||
<span className="text-[10px] font-bold text-dash-text">{server.storage}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center mt-4 pt-3 text-[10px] font-bold uppercase tracking-wider text-dash-textMuted/40">
|
|
||||||
<span>ID: {server.id.substring(0, 8)}...</span>
|
|
||||||
<span>{new Date(server.created_at).toLocaleDateString()}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import branding from '../../branding';
|
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import {
|
import {
|
||||||
Terminal as TerminalIcon,
|
Terminal as TerminalIcon,
|
||||||
@@ -12,13 +11,13 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Copy,
|
Copy,
|
||||||
Check,
|
Check,
|
||||||
Server,
|
|
||||||
Monitor
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { colors } from '../../theme/colors';
|
import { colors } from '../../theme/colors';
|
||||||
import useWebSocket from '../../hooks/useWebSocket';
|
import useWebSocket from '../../hooks/useWebSocket';
|
||||||
import { WS_BASE_URL } from '../../config/api';
|
import { WS_BASE_URL } from '../../config/api';
|
||||||
|
|
||||||
|
const MAX_TERMINAL_OUTPUT_CHARS = 200_000;
|
||||||
|
|
||||||
const NodeTerminalModal = ({ node, onClose }) => {
|
const NodeTerminalModal = ({ node, onClose }) => {
|
||||||
const [terminalOutput, setTerminalOutput] = useState('');
|
const [terminalOutput, setTerminalOutput] = useState('');
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
@@ -27,6 +26,10 @@ const NodeTerminalModal = ({ node, onClose }) => {
|
|||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const outputRef = useRef(null);
|
const outputRef = useRef(null);
|
||||||
const token = localStorage.getItem('anchorhost_token');
|
const token = localStorage.getItem('anchorhost_token');
|
||||||
|
const appendTerminalOutput = useCallback((text) => {
|
||||||
|
if (!text) return;
|
||||||
|
setTerminalOutput((previous) => `${previous}${text}`.slice(-MAX_TERMINAL_OUTPUT_CHARS));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
sendMessage,
|
sendMessage,
|
||||||
@@ -58,12 +61,12 @@ const NodeTerminalModal = ({ node, onClose }) => {
|
|||||||
const connectTerminal = useCallback(() => {
|
const connectTerminal = useCallback(() => {
|
||||||
if (!node || !sendMessage) return;
|
if (!node || !sendMessage) return;
|
||||||
setIsLoading(true);
|
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({
|
sendMessage({
|
||||||
type: 'node_terminal_connect',
|
type: 'node_terminal_connect',
|
||||||
nodeName: node.name
|
nodeName: node.name
|
||||||
});
|
});
|
||||||
}, [node, sendMessage]);
|
}, [appendTerminalOutput, node, sendMessage]);
|
||||||
|
|
||||||
const disconnectTerminal = useCallback(() => {
|
const disconnectTerminal = useCallback(() => {
|
||||||
if (!node || !sendMessage) return;
|
if (!node || !sendMessage) return;
|
||||||
@@ -91,14 +94,19 @@ const NodeTerminalModal = ({ node, onClose }) => {
|
|||||||
if (data.type === 'node_terminal_output') {
|
if (data.type === 'node_terminal_output') {
|
||||||
if (!isConnected) setIsConnected(true);
|
if (!isConnected) setIsConnected(true);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setTerminalOutput(prev => prev + cleanOutput(data.output || ''));
|
appendTerminalOutput(cleanOutput(data.output || ''));
|
||||||
} else if (data.type === 'error' && data.nodeName === node.name) {
|
} else if (data.type === 'node_terminal_error') {
|
||||||
setTerminalOutput(prev => prev + `\r\n\x1b[31m[Error] ${data.error}\x1b[0m\r\n`);
|
setIsConnected(false);
|
||||||
setIsLoading(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) => {
|
const handleTerminalInput = (input) => {
|
||||||
if (!isConnected || !sendMessage) return;
|
if (!isConnected || !sendMessage) return;
|
||||||
@@ -116,7 +124,10 @@ const NodeTerminalModal = ({ node, onClose }) => {
|
|||||||
handleTerminalInput('\r');
|
handleTerminalInput('\r');
|
||||||
} else if (e.key === 'Backspace') {
|
} else if (e.key === 'Backspace') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleTerminalInput('\b');
|
handleTerminalInput('\x7f');
|
||||||
|
} else if (e.key === 'Delete') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleTerminalInput('\x1b[3~');
|
||||||
} else if (e.key === 'Tab') {
|
} else if (e.key === 'Tab') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleTerminalInput('\t');
|
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 = {
|
const ansiToStyle = {
|
||||||
0: { color: 'inherit', fontWeight: 'normal', textDecoration: 'none' },
|
0: { color: 'inherit', fontWeight: 'normal', textDecoration: 'none' },
|
||||||
1: { fontWeight: 'bold' },
|
1: { fontWeight: 'bold' },
|
||||||
@@ -210,124 +230,98 @@ const NodeTerminalModal = ({ node, onClose }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const modalContent = (
|
const modalContent = (
|
||||||
<div className="fixed inset-0 bg-black/90 z-[9999] flex items-center justify-center p-2 sm:p-4 md:p-6 animate-in fade-in duration-200">
|
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/90 p-2 sm:p-5">
|
||||||
<div
|
<div
|
||||||
className={`bg-dash-surface border-2 border-dash-border shadow-[6px_6px_0_0_rgba(0,0,0,0.8)] flex flex-col overflow-hidden transition-all duration-300 ${
|
className={`flex min-h-0 flex-col overflow-hidden border-2 border-dash-border bg-dash-surface shadow-[6px_6px_0_0_rgba(0,0,0,0.8)] ${
|
||||||
isFullscreen ? 'w-full h-full rounded-none' : 'w-full max-w-5xl h-[85vh]'
|
isFullscreen ? 'fixed inset-0 h-full w-full max-w-none' : 'h-[calc(100dvh-1rem)] max-h-[760px] w-full max-w-[1100px] sm:h-[min(78dvh,760px)] sm:min-h-[360px]'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
<header className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b-2 border-dash-border bg-dash-surfaceElevated px-3 py-2.5 sm:px-4">
|
||||||
<div className="flex items-center justify-between px-5 py-3.5 border-b-2 border-dash-border bg-dash-surfaceElevated">
|
<div className="flex min-w-0 items-center gap-3">
|
||||||
<div className="flex items-center space-x-3 min-w-0">
|
<TerminalIcon className="h-4 w-4 shrink-0 text-primary" aria-hidden="true" />
|
||||||
<div className="p-2 bg-primary/10 border-2 border-primary/30 text-primary">
|
<h2 className="max-w-[45vw] truncate font-mono text-sm font-bold text-dash-text sm:max-w-none">{node.name}</h2>
|
||||||
<TerminalIcon className="w-5 h-5" />
|
<span className="flex items-center gap-2 text-xs text-dash-textMuted" role="status" aria-live="polite">
|
||||||
</div>
|
<span className={`h-2 w-2 ${isConnected ? 'bg-brightGreen' : isLoading ? 'animate-pulse bg-brightYellow' : 'bg-brightRed'}`} />
|
||||||
<div className="min-w-0">
|
{isConnected ? 'Connected' : isLoading ? 'Connecting' : 'Offline'}
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<h3 className="font-bold text-sm md:text-base text-dash-text truncate">
|
|
||||||
Host terminal · {node.name}
|
|
||||||
</h3>
|
|
||||||
<span className={`px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider border-2 ${
|
|
||||||
node.role?.includes('Control')
|
|
||||||
? 'bg-brightMagenta/10 border-brightMagenta/20 text-brightMagenta'
|
|
||||||
: 'bg-primary/10 border-primary/20 text-primary'
|
|
||||||
}`}>
|
|
||||||
{node.role || 'Node'}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-3 text-xs text-dash-textMuted mt-0.5 font-medium">
|
|
||||||
<div className="flex items-center space-x-1.5">
|
|
||||||
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-brightGreen animate-pulse' : isLoading ? 'bg-brightYellow animate-ping' : 'bg-brightRed'}`} />
|
|
||||||
<span className={isConnected ? 'text-brightGreen font-semibold' : isLoading ? 'text-brightYellow' : 'text-brightRed'}>
|
|
||||||
{isConnected ? 'Connected' : isLoading ? 'Connecting...' : 'Disconnected'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{node.os?.image && (
|
|
||||||
<span className="hidden sm:inline-flex items-center opacity-60">
|
|
||||||
<Monitor className="w-3 h-3 mr-1" />
|
|
||||||
{node.os.image}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{node.specs?.cpu && (
|
|
||||||
<span className="hidden md:inline-flex items-center opacity-60">
|
|
||||||
<Server className="w-3 h-3 mr-1" />
|
|
||||||
{node.specs.cpu} / {node.specs.ram}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
{!isConnected ? (
|
{!isConnected ? (
|
||||||
<button
|
<button
|
||||||
onClick={connectTerminal}
|
onClick={connectTerminal}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="px-3.5 py-1.5 text-xs font-bold transition-all bg-primary hover:bg-primary/90 text-white flex items-center space-x-1.5 border-2 border-dash-border shadow-[3px_3px_0_0_rgba(0,0,0,0.6)] disabled:opacity-50"
|
className="flex h-9 items-center gap-2 border-2 border-dash-border bg-primary px-3 text-xs font-bold text-white disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{isLoading ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <Power className="w-3.5 h-3.5" />}
|
{isLoading ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <Power className="w-3.5 h-3.5" />}
|
||||||
<span>{isLoading ? 'Connecting' : 'Connect'}</span>
|
<span>{isLoading ? 'Connecting' : 'Reconnect'}</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={disconnectTerminal}
|
onClick={disconnectTerminal}
|
||||||
className="px-3.5 py-1.5 text-xs font-bold transition-all bg-brightRed/10 hover:bg-brightRed/20 border-2 border-brightRed/50 text-brightRed flex items-center space-x-1.5"
|
aria-label="Disconnect terminal"
|
||||||
|
title="Disconnect"
|
||||||
|
className="flex h-9 w-9 items-center justify-center border-2 border-dash-border text-brightRed hover:bg-brightRed/10"
|
||||||
>
|
>
|
||||||
<Square className="w-3.5 h-3.5 fill-current" />
|
<Square className="w-3.5 h-3.5 fill-current" />
|
||||||
<span>Disconnect</span>
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setTerminalOutput('')}
|
onClick={() => setTerminalOutput('')}
|
||||||
className="p-2 rounded-xl text-dash-textMuted hover:text-dash-text hover:bg-white/5 transition-colors border border-transparent hover:border-dash-border/40"
|
className="flex h-9 w-9 items-center justify-center border-2 border-transparent text-dash-textMuted hover:border-dash-border hover:text-dash-text"
|
||||||
title="Clear Terminal"
|
aria-label="Clear terminal output"
|
||||||
|
title="Clear output"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleCopyLogs}
|
onClick={handleCopyLogs}
|
||||||
className="p-2 rounded-xl text-dash-textMuted hover:text-dash-text hover:bg-white/5 transition-colors border border-transparent hover:border-dash-border/40"
|
className="flex h-9 w-9 items-center justify-center border-2 border-transparent text-dash-textMuted hover:border-dash-border hover:text-dash-text"
|
||||||
title="Copy Output"
|
aria-label="Copy terminal output"
|
||||||
|
title="Copy output"
|
||||||
>
|
>
|
||||||
{copied ? <Check className="w-4 h-4 text-brightGreen" /> : <Copy className="w-4 h-4" />}
|
{copied ? <Check className="w-4 h-4 text-brightGreen" /> : <Copy className="w-4 h-4" />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||||
className="p-2 rounded-xl text-dash-textMuted hover:text-dash-text hover:bg-white/5 transition-colors border border-transparent hover:border-dash-border/40"
|
className="flex h-9 w-9 items-center justify-center border-2 border-transparent text-dash-textMuted hover:border-dash-border hover:text-dash-text"
|
||||||
title={isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'}
|
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
||||||
|
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||||
>
|
>
|
||||||
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-2 rounded-xl text-dash-textMuted hover:text-brightRed hover:bg-brightRed/10 transition-colors border border-transparent hover:border-brightRed/20 ml-1"
|
className="flex h-9 w-9 items-center justify-center border-2 border-transparent text-dash-textMuted hover:border-brightRed hover:text-brightRed"
|
||||||
|
aria-label="Close terminal"
|
||||||
title="Close"
|
title="Close"
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
{/* Terminal Screen */}
|
{/* Terminal Screen */}
|
||||||
<div
|
<div
|
||||||
ref={outputRef}
|
ref={outputRef}
|
||||||
onClick={() => outputRef.current?.focus()}
|
onClick={() => outputRef.current?.focus()}
|
||||||
onKeyDown={handleKeyDown}
|
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"
|
role="textbox"
|
||||||
aria-multiline="true"
|
aria-multiline="true"
|
||||||
aria-readonly="false"
|
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}
|
tabIndex={0}
|
||||||
autoFocus
|
autoFocus
|
||||||
>
|
>
|
||||||
{terminalOutput ? parseAnsi(terminalOutput) : (
|
{terminalOutput ? parseAnsi(terminalOutput) : (
|
||||||
<div className="text-dash-textMuted/60 italic">
|
<div className="text-dash-textMuted">
|
||||||
Initializing interactive shell session to node {node.name}...
|
{isLoading ? `Connecting to ${node.name}…` : 'Waiting for the host login prompt…'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isConnected && (
|
{isConnected && (
|
||||||
@@ -335,18 +329,12 @@ const NodeTerminalModal = ({ node, onClose }) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer info banner */}
|
<footer className="flex shrink-0 flex-wrap gap-x-4 gap-y-1 border-t-2 border-dash-border bg-dash-surfaceElevated px-3 py-2 font-mono text-[11px] text-dash-textMuted sm:px-4">
|
||||||
<div className="px-5 py-2.5 border-t-2 border-dash-border bg-dash-surfaceElevated text-[11px] text-dash-textMuted flex items-center justify-between">
|
<span>Backspace erase</span>
|
||||||
<div className="flex items-center space-x-2">
|
<span>Tab complete</span>
|
||||||
<span className="font-mono">root@{node.name}:~#</span>
|
<span>Ctrl+C interrupt</span>
|
||||||
<span className="text-brightYellow">| Privileged host shell</span>
|
<span>Ctrl+D logout</span>
|
||||||
</div>
|
</footer>
|
||||||
<div className="hidden sm:flex items-center space-x-4">
|
|
||||||
<span>Ctrl+C: Cancel</span>
|
|
||||||
<span>Ctrl+L: Clear</span>
|
|
||||||
<span>Ctrl+D: Exit</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Play,
|
Play,
|
||||||
Server,
|
Server,
|
||||||
Square,
|
Square,
|
||||||
|
Settings,
|
||||||
Terminal,
|
Terminal,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ActionButton, getServerStatusInfo } from '../../components/DashboardComponents.jsx';
|
import { ActionButton, getServerStatusInfo } from '../../components/DashboardComponents.jsx';
|
||||||
@@ -47,6 +48,8 @@ export default function ServerCard({
|
|||||||
onConvertDemo,
|
onConvertDemo,
|
||||||
onOpenConsole,
|
onOpenConsole,
|
||||||
onServerAction,
|
onServerAction,
|
||||||
|
owner,
|
||||||
|
isAdminView = false,
|
||||||
}) {
|
}) {
|
||||||
const statusInfo = getServerStatusInfo(server.status);
|
const statusInfo = getServerStatusInfo(server.status);
|
||||||
const { icon: ServerIcon, color: serverColor } = serverTypeIcons[server.type] || serverTypeIcons.vps;
|
const { icon: ServerIcon, color: serverColor } = serverTypeIcons[server.type] || serverTypeIcons.vps;
|
||||||
@@ -95,6 +98,7 @@ export default function ServerCard({
|
|||||||
<h3 className="ah-server-name" title={server.name}>{server.name}</h3>
|
<h3 className="ah-server-name" title={server.name}>{server.name}</h3>
|
||||||
{isDemo && <span className="ah-server-demo-tag">DEMO</span>}
|
{isDemo && <span className="ah-server-demo-tag">DEMO</span>}
|
||||||
</div>
|
</div>
|
||||||
|
{owner && <p className="ah-server-owner">Owner: {owner}</p>}
|
||||||
<p className="ah-server-type-label">{isDatabase ? `${dbType} db` : `${server.type} node`}</p>
|
<p className="ah-server-type-label">{isDatabase ? `${dbType} db` : `${server.type} node`}</p>
|
||||||
{isDemo && timeRemaining && (
|
{isDemo && timeRemaining && (
|
||||||
<div className="ah-server-demo-time">
|
<div className="ah-server-demo-time">
|
||||||
@@ -152,10 +156,10 @@ export default function ServerCard({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ActionButton variant="secondary" onClick={() => onOpenConsole(server)} className="flex-1 !min-h-0 !py-3">
|
<ActionButton variant="secondary" onClick={() => onOpenConsole(server)} className="flex-1 !min-h-0 !py-3">
|
||||||
<Terminal className="w-4 h-4" />
|
{isAdminView ? <Settings className="w-4 h-4" /> : <Terminal className="w-4 h-4" />}
|
||||||
<span className="font-black uppercase tracking-widest text-[10px]">Console</span>
|
<span className="font-black uppercase tracking-widest text-[10px]">{isAdminView ? 'Manage' : 'Console'}</span>
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
{isDemo && (
|
{isDemo && !isAdminView && (
|
||||||
<ActionButton
|
<ActionButton
|
||||||
variant="warning"
|
variant="warning"
|
||||||
onClick={() => onConvertDemo(server.id)}
|
onClick={() => onConvertDemo(server.id)}
|
||||||
|
|||||||
@@ -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 { min-width: 0; flex: 1; }
|
||||||
.ah-server-card-heading > .flex { min-width: 0; align-items: center; }
|
.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-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-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-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); }
|
.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); }
|
||||||
|
|||||||
Reference in new issue
Block a user