Implement file browser & web ui components
This commit is contained in:
@@ -5,7 +5,7 @@ import "@/common/styles/main.sass";
|
||||
import Root from "@/common/layouts/Root.jsx";
|
||||
import UserManagement from "@/pages/UserManagement";
|
||||
import SystemSettings from "@/pages/SystemSettings";
|
||||
import Machines from "@/pages/Machines";
|
||||
import Machines, {MachineDetails} from "@/pages/Machines";
|
||||
import "@fontsource/plus-jakarta-sans/300.css";
|
||||
import "@fontsource/plus-jakarta-sans/400.css";
|
||||
import "@fontsource/plus-jakarta-sans/600.css";
|
||||
@@ -24,6 +24,7 @@ const App = () => {
|
||||
{path: "/", element: <Navigate to="/dashboard"/>},
|
||||
{path: "/dashboard", element: <Placeholder title="Dashboard"/>},
|
||||
{path: "/machines", element: <Machines/>},
|
||||
{path: "/machines/:id", element: <MachineDetails/>},
|
||||
{path: "/servers", element: <Placeholder title="Servers"/>},
|
||||
{path: "/settings", element: <Placeholder title="Settings"/>},
|
||||
{path: "/admin/users", element: <UserManagement/>},
|
||||
|
@@ -45,5 +45,9 @@
|
||||
color: #1976d2
|
||||
|
||||
&--user
|
||||
background: var(--bg-elev)
|
||||
color: var(--text-dim)
|
||||
|
||||
&--subtle
|
||||
background: var(--bg-elev)
|
||||
color: var(--text-dim)
|
@@ -6,10 +6,19 @@ export const EmptyState = ({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
size = 'md',
|
||||
variant = 'default',
|
||||
className = ''
|
||||
}) => {
|
||||
const emptyStateClasses = [
|
||||
'empty-state',
|
||||
`empty-state--${size}`,
|
||||
`empty-state--${variant}`,
|
||||
className
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
return (
|
||||
<div className={`empty-state ${className}`}>
|
||||
<div className={emptyStateClasses}>
|
||||
{icon && <div className="empty-state-icon">{icon}</div>}
|
||||
{title && <h3 className="empty-state-title">{title}</h3>}
|
||||
{description && <p className="empty-state-description">{description}</p>}
|
||||
|
@@ -7,6 +7,57 @@
|
||||
align-items: center
|
||||
gap: 1rem
|
||||
|
||||
&--sm
|
||||
padding: 1.5rem 1rem
|
||||
gap: 0.5rem
|
||||
|
||||
.empty-state-icon svg
|
||||
width: 32px
|
||||
height: 32px
|
||||
|
||||
.empty-state-title
|
||||
font-size: 1rem
|
||||
|
||||
.empty-state-description
|
||||
font-size: 0.875rem
|
||||
|
||||
&--md
|
||||
padding: 3rem 2rem
|
||||
gap: 1rem
|
||||
|
||||
.empty-state-icon svg
|
||||
width: 48px
|
||||
height: 48px
|
||||
|
||||
.empty-state-title
|
||||
font-size: 1.2rem
|
||||
|
||||
.empty-state-description
|
||||
font-size: 0.95rem
|
||||
|
||||
&--lg
|
||||
padding: 4rem 3rem
|
||||
gap: 1.5rem
|
||||
|
||||
.empty-state-icon svg
|
||||
width: 64px
|
||||
height: 64px
|
||||
|
||||
.empty-state-title
|
||||
font-size: 1.5rem
|
||||
|
||||
.empty-state-description
|
||||
font-size: 1rem
|
||||
|
||||
&--subtle
|
||||
opacity: 0.8
|
||||
|
||||
.empty-state-icon
|
||||
color: var(--text-dim)
|
||||
|
||||
.empty-state-title
|
||||
color: var(--text-dim)
|
||||
|
||||
.empty-state-icon
|
||||
color: var(--text-dim)
|
||||
display: flex
|
||||
|
514
webui/src/common/components/FileBrowser/FileBrowser.jsx
Normal file
514
webui/src/common/components/FileBrowser/FileBrowser.jsx
Normal file
@@ -0,0 +1,514 @@
|
||||
import React, {useState, useEffect} from 'react';
|
||||
import {useToast} from '@/common/contexts/ToastContext.jsx';
|
||||
import {getRequest} from '@/common/utils/RequestUtil.js';
|
||||
import Button from '@/common/components/Button';
|
||||
import Modal, {ModalActions} from '@/common/components/Modal';
|
||||
import Card, {CardHeader, CardBody} from '@/common/components/Card';
|
||||
import LoadingSpinner from '@/common/components/LoadingSpinner';
|
||||
import EmptyState from '@/common/components/EmptyState';
|
||||
import {
|
||||
FolderIcon,
|
||||
FileIcon,
|
||||
ArrowLeftIcon,
|
||||
DownloadIcon,
|
||||
LinkIcon,
|
||||
XIcon,
|
||||
FolderOpenIcon,
|
||||
HouseIcon,
|
||||
FileTextIcon,
|
||||
FilePdfIcon,
|
||||
FileZipIcon,
|
||||
FileImageIcon,
|
||||
FileVideoIcon,
|
||||
FileAudioIcon,
|
||||
FileCodeIcon,
|
||||
GearIcon,
|
||||
Database
|
||||
} from '@phosphor-icons/react';
|
||||
import './file-browser.sass';
|
||||
|
||||
export const FileBrowser = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
machineId,
|
||||
snapshotId,
|
||||
partitionIndex,
|
||||
partitionInfo
|
||||
}) => {
|
||||
const toast = useToast();
|
||||
const [currentPath, setCurrentPath] = useState([]);
|
||||
const [currentDirHash, setCurrentDirHash] = useState(null);
|
||||
const [entries, setEntries] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [breadcrumbs, setBreadcrumbs] = useState([{name: 'Root', hash: null}]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && machineId && snapshotId && partitionIndex !== undefined) {
|
||||
loadPartitionRoot();
|
||||
}
|
||||
}, [isOpen, machineId, snapshotId, partitionIndex]);
|
||||
|
||||
const loadPartitionRoot = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await getRequest(
|
||||
`machines/${machineId}/snapshots/${snapshotId}/partitions/${partitionIndex}/files`
|
||||
);
|
||||
setEntries(response.entries || []);
|
||||
setCurrentPath([]);
|
||||
setCurrentDirHash(null);
|
||||
setBreadcrumbs([{name: 'Root', hash: null}]);
|
||||
} catch (error) {
|
||||
console.error('Failed to load partition root:', error);
|
||||
toast.error('Failed to load files. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadDirectory = async (dirHash, dirName) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await getRequest(
|
||||
`machines/${machineId}/snapshots/${snapshotId}/partitions/${partitionIndex}/files/${dirHash}`
|
||||
);
|
||||
setEntries(response.entries || []);
|
||||
setCurrentDirHash(dirHash);
|
||||
|
||||
// Update path and breadcrumbs
|
||||
const newPath = [...currentPath, dirName];
|
||||
setCurrentPath(newPath);
|
||||
|
||||
const newBreadcrumbs = [
|
||||
{name: 'Root', hash: null},
|
||||
...newPath.map((name, index) => ({
|
||||
name,
|
||||
hash: index === newPath.length - 1 ? dirHash : null // Only store hash for current dir
|
||||
}))
|
||||
];
|
||||
setBreadcrumbs(newBreadcrumbs);
|
||||
} catch (error) {
|
||||
console.error('Failed to load directory:', error);
|
||||
toast.error('Failed to load directory. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const navigateToBreadcrumb = async (index) => {
|
||||
if (index === 0) {
|
||||
// Navigate to root
|
||||
await loadPartitionRoot();
|
||||
} else {
|
||||
// For now, we can only navigate back to root since we don't store intermediate hashes
|
||||
// In a full implementation, you'd need to track the full path with hashes
|
||||
toast.info('Navigation to intermediate directories is not implemented yet. Use the back button or go to root.');
|
||||
}
|
||||
};
|
||||
|
||||
const goBack = async () => {
|
||||
if (currentPath.length === 0) {
|
||||
return; // Already at root
|
||||
}
|
||||
|
||||
if (currentPath.length === 1) {
|
||||
// Go back to root
|
||||
await loadPartitionRoot();
|
||||
} else {
|
||||
// For now, just go to root. Full implementation would require tracking parent hashes
|
||||
await loadPartitionRoot();
|
||||
toast.info('Navigated back to root. Full directory navigation will be enhanced in future updates.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEntryClick = async (entry) => {
|
||||
if (entry.entry_type === 'dir') {
|
||||
await loadDirectory(entry.meta_hash, entry.name);
|
||||
} else if (entry.entry_type === 'file') {
|
||||
await downloadFile(entry.meta_hash, entry.name);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadFile = async (fileHash, fileName) => {
|
||||
try {
|
||||
toast.info(`Starting download of ${fileName}...`);
|
||||
|
||||
// Get auth token
|
||||
const token = localStorage.getItem('sessionToken');
|
||||
if (!token) {
|
||||
toast.error('Authentication required. Please log in again.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Make authenticated request to download file
|
||||
const downloadUrl = `/api/machines/${machineId}/snapshots/${snapshotId}/partitions/${partitionIndex}/download/${fileHash}?filename=${encodeURIComponent(fileName)}`;
|
||||
|
||||
const response = await fetch(downloadUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
toast.error('Authentication failed. Please log in again.');
|
||||
return;
|
||||
}
|
||||
throw new Error(`Download failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Get the file as a blob
|
||||
const blob = await response.blob();
|
||||
|
||||
// Create a temporary URL for the blob
|
||||
const blobUrl = window.URL.createObjectURL(blob);
|
||||
|
||||
// Create a temporary anchor element
|
||||
const link = document.createElement('a');
|
||||
link.href = blobUrl;
|
||||
link.download = fileName;
|
||||
link.style.display = 'none';
|
||||
|
||||
// Add to DOM, click, and remove
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// Clean up the blob URL
|
||||
window.URL.revokeObjectURL(blobUrl);
|
||||
|
||||
toast.success(`Downloaded ${fileName}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to download file:', error);
|
||||
toast.error(`Failed to download file: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes) => {
|
||||
if (!bytes) return 'Unknown size';
|
||||
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const getFileIcon = (entry) => {
|
||||
if (entry.entry_type === 'dir') {
|
||||
return <FolderIcon size={20} weight="duotone"/>;
|
||||
} else if (entry.entry_type === 'symlink') {
|
||||
return <LinkIcon size={20} weight="duotone"/>;
|
||||
} else {
|
||||
// Get file extension
|
||||
const extension = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
|
||||
// Return appropriate icon based on extension
|
||||
switch (extension) {
|
||||
case 'txt':
|
||||
case 'md':
|
||||
case 'readme':
|
||||
case 'log':
|
||||
return <FileTextIcon size={20} weight="duotone"/>;
|
||||
case 'pdf':
|
||||
return <FilePdfIcon size={20} weight="duotone"/>;
|
||||
case 'zip':
|
||||
case 'rar':
|
||||
case 'tar':
|
||||
case 'gz':
|
||||
case '7z':
|
||||
return <FileZipIcon size={20} weight="duotone"/>;
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
case 'png':
|
||||
case 'gif':
|
||||
case 'bmp':
|
||||
case 'svg':
|
||||
case 'webp':
|
||||
return <FileImageIcon size={20} weight="duotone"/>;
|
||||
case 'mp4':
|
||||
case 'avi':
|
||||
case 'mov':
|
||||
case 'wmv':
|
||||
case 'flv':
|
||||
case 'webm':
|
||||
return <FileVideoIcon size={20} weight="duotone"/>;
|
||||
case 'mp3':
|
||||
case 'wav':
|
||||
case 'flac':
|
||||
case 'aac':
|
||||
case 'ogg':
|
||||
return <FileAudioIcon size={20} weight="duotone"/>;
|
||||
case 'js':
|
||||
case 'ts':
|
||||
case 'jsx':
|
||||
case 'tsx':
|
||||
case 'html':
|
||||
case 'css':
|
||||
case 'scss':
|
||||
case 'sass':
|
||||
case 'json':
|
||||
case 'xml':
|
||||
case 'py':
|
||||
case 'java':
|
||||
case 'cpp':
|
||||
case 'c':
|
||||
case 'h':
|
||||
case 'rs':
|
||||
case 'go':
|
||||
case 'php':
|
||||
case 'rb':
|
||||
case 'sh':
|
||||
case 'sql':
|
||||
return <FileCodeIcon size={20} weight="duotone"/>;
|
||||
case 'exe':
|
||||
case 'msi':
|
||||
case 'deb':
|
||||
case 'rpm':
|
||||
case 'dmg':
|
||||
case 'app':
|
||||
return <GearIcon size={20} weight="duotone"/>;
|
||||
case 'db':
|
||||
case 'sqlite':
|
||||
case 'mysql':
|
||||
return <Database size={20} weight="duotone"/>;
|
||||
default:
|
||||
return <FileIcon size={20} weight="duotone"/>;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getEntryTypeColor = (entry) => {
|
||||
if (entry.entry_type === 'dir') {
|
||||
return '#4589ff'; // Blue for directories
|
||||
} else if (entry.entry_type === 'symlink') {
|
||||
return '#a855f7'; // Purple for symlinks
|
||||
} else {
|
||||
// Color code by file extension
|
||||
const extension = entry.name.split('.').pop()?.toLowerCase() || '';
|
||||
|
||||
switch (extension) {
|
||||
case 'txt':
|
||||
case 'md':
|
||||
case 'readme':
|
||||
case 'log':
|
||||
return '#16a34a'; // Green for text files
|
||||
case 'pdf':
|
||||
return '#dc2626'; // Red for PDFs
|
||||
case 'zip':
|
||||
case 'rar':
|
||||
case 'tar':
|
||||
case 'gz':
|
||||
case '7z':
|
||||
return '#f59e0b'; // Orange for archives
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
case 'png':
|
||||
case 'gif':
|
||||
case 'bmp':
|
||||
case 'svg':
|
||||
case 'webp':
|
||||
return '#ec4899'; // Pink for images
|
||||
case 'mp4':
|
||||
case 'avi':
|
||||
case 'mov':
|
||||
case 'wmv':
|
||||
case 'flv':
|
||||
case 'webm':
|
||||
return '#8b5cf6'; // Purple for videos
|
||||
case 'mp3':
|
||||
case 'wav':
|
||||
case 'flac':
|
||||
case 'aac':
|
||||
case 'ogg':
|
||||
return '#06b6d4'; // Cyan for audio
|
||||
case 'js':
|
||||
case 'ts':
|
||||
case 'jsx':
|
||||
case 'tsx':
|
||||
case 'html':
|
||||
case 'css':
|
||||
case 'scss':
|
||||
case 'sass':
|
||||
case 'json':
|
||||
case 'xml':
|
||||
case 'py':
|
||||
case 'java':
|
||||
case 'cpp':
|
||||
case 'c':
|
||||
case 'h':
|
||||
case 'rs':
|
||||
case 'go':
|
||||
case 'php':
|
||||
case 'rb':
|
||||
case 'sh':
|
||||
case 'sql':
|
||||
return '#10b981'; // Emerald for code files
|
||||
case 'exe':
|
||||
case 'msi':
|
||||
case 'deb':
|
||||
case 'rpm':
|
||||
case 'dmg':
|
||||
case 'app':
|
||||
return '#6b7280'; // Gray for executables
|
||||
case 'db':
|
||||
case 'sqlite':
|
||||
case 'mysql':
|
||||
return '#0ea5e9'; // Blue for databases
|
||||
default:
|
||||
return '#6b7280'; // Default gray
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={`File Browser - ${partitionInfo?.fs_type?.toUpperCase() || 'Partition'} Partition`}
|
||||
size="xl"
|
||||
className="file-browser-modal"
|
||||
>
|
||||
<div className="file-browser">
|
||||
{/* Navigation Bar */}
|
||||
<div className="file-browser-nav">
|
||||
<div className="nav-controls">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
icon={<ArrowLeftIcon size={16}/>}
|
||||
onClick={goBack}
|
||||
disabled={currentPath.length === 0 || loading}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
icon={<HouseIcon size={16}/>}
|
||||
onClick={loadPartitionRoot}
|
||||
disabled={currentPath.length === 0 || loading}
|
||||
>
|
||||
Root
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumbs */}
|
||||
<div className="breadcrumbs">
|
||||
{breadcrumbs.map((crumb, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{index > 0 && <span className="breadcrumb-separator">/</span>}
|
||||
<button
|
||||
className={`breadcrumb ${index === breadcrumbs.length - 1 ? 'breadcrumb--current' : ''}`}
|
||||
onClick={() => navigateToBreadcrumb(index)}
|
||||
disabled={index === breadcrumbs.length - 1 || loading}
|
||||
>
|
||||
{crumb.name}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* File List */}
|
||||
<div className="file-browser-content">
|
||||
{loading ? (
|
||||
<div className="file-browser-loading">
|
||||
<LoadingSpinner text="Loading directory contents..."/>
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<FolderOpenIcon size={48} weight="duotone"/>}
|
||||
title="Empty directory"
|
||||
description="This directory doesn't contain any files or subdirectories"
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* File List Header */}
|
||||
<div className="file-list-header">
|
||||
<div className="file-list-header-item">
|
||||
<span>Name</span>
|
||||
</div>
|
||||
<div className="file-list-header-item">
|
||||
<span>Type</span>
|
||||
</div>
|
||||
<div className="file-list-header-item">
|
||||
<span>Size</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="file-list">
|
||||
{entries.map((entry, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`file-entry file-entry--${entry.entry_type}`}
|
||||
onClick={() => handleEntryClick(entry)}
|
||||
>
|
||||
<div className="file-entry-main">
|
||||
<div className="file-entry-icon" style={{color: getEntryTypeColor(entry)}}>
|
||||
{getFileIcon(entry)}
|
||||
</div>
|
||||
<div className="file-entry-info">
|
||||
<div className="file-entry-name">
|
||||
{entry.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="file-entry-type-cell">
|
||||
<span className="file-entry-type">
|
||||
{entry.entry_type === 'dir' ? 'Folder' :
|
||||
entry.entry_type === 'symlink' ? 'Link' :
|
||||
entry.name.includes('.') ? entry.name.split('.').pop()?.toUpperCase() + ' File' : 'File'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="file-entry-size-cell">
|
||||
{entry.size_bytes ? (
|
||||
<span className="file-entry-size">
|
||||
{formatFileSize(entry.size_bytes)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="file-entry-size-empty">—</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{entry.entry_type === 'file' && (
|
||||
<div className="file-entry-actions">
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
icon={<DownloadIcon size={14}/>}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
downloadFile(entry.meta_hash, entry.name);
|
||||
}}
|
||||
title="Download file"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ModalActions>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={onClose}
|
||||
icon={<XIcon size={16}/>}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</ModalActions>
|
||||
</Modal>
|
||||
);
|
||||
};
|
356
webui/src/common/components/FileBrowser/file-browser.sass
Normal file
356
webui/src/common/components/FileBrowser/file-browser.sass
Normal file
@@ -0,0 +1,356 @@
|
||||
// File Browser Styles - Modern File Manager Design
|
||||
.file-browser-modal
|
||||
.modal-dialog
|
||||
max-width: 95vw
|
||||
width: 1200px
|
||||
height: 85vh
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
.modal-content
|
||||
display: flex
|
||||
flex-direction: column
|
||||
height: 100%
|
||||
overflow: hidden
|
||||
|
||||
.modal-body
|
||||
flex: 1
|
||||
padding: 0
|
||||
display: flex
|
||||
flex-direction: column
|
||||
overflow: hidden
|
||||
|
||||
.file-browser
|
||||
display: flex
|
||||
flex-direction: column
|
||||
height: 100%
|
||||
background: var(--bg-alt)
|
||||
border-radius: var(--radius)
|
||||
overflow: hidden
|
||||
|
||||
// Navigation Bar - File Explorer Style
|
||||
.file-browser-nav
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: space-between
|
||||
padding: 0.75rem 1rem
|
||||
border-bottom: 1px solid var(--border)
|
||||
background: linear-gradient(to bottom, var(--bg-alt), var(--bg-elev))
|
||||
gap: 1rem
|
||||
min-height: 60px
|
||||
|
||||
.nav-controls
|
||||
display: flex
|
||||
gap: 0.5rem
|
||||
flex-shrink: 0
|
||||
|
||||
.breadcrumbs
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 0.25rem
|
||||
flex: 1
|
||||
min-width: 0
|
||||
overflow-x: auto
|
||||
padding: 0.5rem
|
||||
background: var(--bg)
|
||||
border: 1px solid var(--border)
|
||||
border-radius: var(--radius-sm)
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace
|
||||
font-size: 0.875rem
|
||||
|
||||
.breadcrumb
|
||||
background: none
|
||||
border: none
|
||||
color: var(--accent)
|
||||
cursor: pointer
|
||||
font-size: 0.875rem
|
||||
padding: 0.25rem 0.5rem
|
||||
border-radius: var(--radius-sm)
|
||||
transition: all 0.2s ease
|
||||
white-space: nowrap
|
||||
font-weight: 500
|
||||
|
||||
&:hover:not(:disabled)
|
||||
background: rgba(15, 98, 254, 0.1)
|
||||
color: var(--accent)
|
||||
|
||||
&:disabled
|
||||
color: var(--text-dim)
|
||||
cursor: default
|
||||
|
||||
&--current
|
||||
color: var(--text)
|
||||
font-weight: 600
|
||||
cursor: default
|
||||
background: rgba(15, 98, 254, 0.05)
|
||||
|
||||
&:hover
|
||||
background: rgba(15, 98, 254, 0.05)
|
||||
|
||||
.breadcrumb-separator
|
||||
color: var(--text-dim)
|
||||
font-size: 0.875rem
|
||||
margin: 0 0.25rem
|
||||
font-weight: 400
|
||||
|
||||
// Content Area
|
||||
.file-browser-content
|
||||
flex: 1
|
||||
display: flex
|
||||
flex-direction: column
|
||||
overflow: hidden
|
||||
background: var(--bg)
|
||||
|
||||
.file-browser-loading
|
||||
flex: 1
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
padding: 3rem
|
||||
|
||||
// File List - Table-like layout with header
|
||||
.file-list-header
|
||||
display: flex
|
||||
background: var(--bg-elev)
|
||||
border-bottom: 1px solid var(--border)
|
||||
padding: 0.75rem 1rem
|
||||
font-size: 0.75rem
|
||||
font-weight: 600
|
||||
color: var(--text-dim)
|
||||
text-transform: uppercase
|
||||
letter-spacing: 0.025em
|
||||
position: sticky
|
||||
top: 0
|
||||
z-index: 10
|
||||
|
||||
&-item
|
||||
display: flex
|
||||
align-items: center
|
||||
|
||||
&:nth-child(1)
|
||||
flex: 1
|
||||
min-width: 0
|
||||
padding-right: 1rem
|
||||
|
||||
&:nth-child(2)
|
||||
width: 120px
|
||||
padding-right: 1rem
|
||||
|
||||
&:nth-child(3)
|
||||
width: 100px
|
||||
text-align: right
|
||||
|
||||
.file-list
|
||||
flex: 1
|
||||
overflow-y: auto
|
||||
padding: 0
|
||||
background: var(--bg)
|
||||
|
||||
.file-entry
|
||||
display: flex
|
||||
align-items: center
|
||||
padding: 0.75rem 1rem
|
||||
border-bottom: 1px solid rgba(223, 227, 232, 0.3)
|
||||
cursor: pointer
|
||||
transition: all 0.15s ease
|
||||
user-select: none
|
||||
position: relative
|
||||
|
||||
&:hover
|
||||
background: linear-gradient(90deg, rgba(15, 98, 254, 0.03), rgba(15, 98, 254, 0.01))
|
||||
border-left: 3px solid rgba(15, 98, 254, 0.3)
|
||||
|
||||
&:active
|
||||
background: rgba(15, 98, 254, 0.08)
|
||||
|
||||
&--dir
|
||||
&:hover
|
||||
background: linear-gradient(90deg, rgba(15, 98, 254, 0.05), rgba(15, 98, 254, 0.02))
|
||||
border-left: 3px solid var(--accent)
|
||||
|
||||
.file-entry-icon
|
||||
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.1))
|
||||
|
||||
// Main content area (icon + name)
|
||||
&-main
|
||||
display: flex
|
||||
align-items: center
|
||||
flex: 1
|
||||
min-width: 0
|
||||
padding-right: 1rem
|
||||
|
||||
&-icon
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
flex-shrink: 0
|
||||
width: 24px
|
||||
height: 24px
|
||||
margin-right: 0.75rem
|
||||
transition: transform 0.2s ease
|
||||
|
||||
.file-entry:hover &
|
||||
transform: scale(1.05)
|
||||
|
||||
&-info
|
||||
flex: 1
|
||||
min-width: 0
|
||||
|
||||
&-name
|
||||
font-weight: 500
|
||||
color: var(--text)
|
||||
word-break: break-word
|
||||
line-height: 1.3
|
||||
font-size: 0.875rem
|
||||
|
||||
.file-entry--dir &
|
||||
font-weight: 600
|
||||
|
||||
// Type column
|
||||
&-type-cell
|
||||
width: 120px
|
||||
padding-right: 1rem
|
||||
flex-shrink: 0
|
||||
|
||||
&-type
|
||||
text-transform: uppercase
|
||||
font-weight: 600
|
||||
letter-spacing: 0.025em
|
||||
font-size: 0.7rem
|
||||
color: var(--text-dim)
|
||||
|
||||
// Size column
|
||||
&-size-cell
|
||||
width: 100px
|
||||
text-align: right
|
||||
flex-shrink: 0
|
||||
|
||||
&-size
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace
|
||||
font-size: 0.7rem
|
||||
color: var(--text-dim)
|
||||
background: var(--bg-elev)
|
||||
padding: 0.125rem 0.375rem
|
||||
border-radius: var(--radius-sm)
|
||||
|
||||
&-size-empty
|
||||
font-size: 0.7rem
|
||||
color: var(--text-dim)
|
||||
opacity: 0.5
|
||||
|
||||
&-actions
|
||||
display: flex
|
||||
gap: 0.25rem
|
||||
flex-shrink: 0
|
||||
opacity: 0
|
||||
transition: opacity 0.2s ease
|
||||
transform: translateX(8px)
|
||||
margin-left: 0.5rem
|
||||
|
||||
&:hover &-actions
|
||||
opacity: 1
|
||||
transform: translateX(0)
|
||||
|
||||
// Empty state styling
|
||||
.file-browser-content .empty-state
|
||||
margin: 3rem auto
|
||||
max-width: 400px
|
||||
|
||||
// Header improvements
|
||||
.file-browser-nav .nav-controls button
|
||||
border: 1px solid var(--border)
|
||||
background: var(--bg)
|
||||
transition: all 0.2s ease
|
||||
|
||||
&:hover:not(:disabled)
|
||||
background: var(--bg-elev)
|
||||
border-color: var(--accent)
|
||||
transform: translateY(-1px)
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1)
|
||||
|
||||
&:disabled
|
||||
opacity: 0.5
|
||||
cursor: not-allowed
|
||||
|
||||
// Scrollbar styling
|
||||
.file-list
|
||||
scrollbar-width: thin
|
||||
scrollbar-color: var(--border) transparent
|
||||
|
||||
&::-webkit-scrollbar
|
||||
width: 8px
|
||||
|
||||
&::-webkit-scrollbar-track
|
||||
background: transparent
|
||||
|
||||
&::-webkit-scrollbar-thumb
|
||||
background: var(--border)
|
||||
border-radius: 4px
|
||||
|
||||
&:hover
|
||||
background: var(--border-strong)
|
||||
|
||||
// File type specific styling
|
||||
.file-entry--dir
|
||||
.file-entry-name
|
||||
color: var(--text)
|
||||
|
||||
.file-entry-type
|
||||
color: #4589ff
|
||||
|
||||
.file-entry--file
|
||||
.file-entry-name
|
||||
color: var(--text)
|
||||
|
||||
.file-entry--symlink
|
||||
.file-entry-name
|
||||
color: var(--text-dim)
|
||||
font-style: italic
|
||||
|
||||
.file-entry-type
|
||||
color: #a855f7
|
||||
|
||||
// Loading state
|
||||
.file-browser-loading .loading-spinner
|
||||
color: var(--accent)
|
||||
|
||||
// Modal actions
|
||||
.file-browser-modal .modal-actions
|
||||
padding: 1rem
|
||||
border-top: 1px solid var(--border)
|
||||
background: var(--bg-elev)
|
||||
|
||||
// Responsive design
|
||||
@media (max-width: 768px)
|
||||
.file-browser-modal .modal-dialog
|
||||
max-width: 98vw
|
||||
width: 98vw
|
||||
height: 90vh
|
||||
|
||||
.file-browser-nav
|
||||
flex-direction: column
|
||||
align-items: stretch
|
||||
gap: 0.75rem
|
||||
padding: 1rem
|
||||
|
||||
.breadcrumbs
|
||||
order: -1
|
||||
font-size: 0.8rem
|
||||
|
||||
.file-entry
|
||||
grid-template-columns: auto 1fr
|
||||
gap: 0.75rem
|
||||
padding: 1rem 0.75rem
|
||||
|
||||
.file-entry-actions
|
||||
grid-column: 1 / -1
|
||||
justify-self: end
|
||||
margin-top: 0.5rem
|
||||
opacity: 1
|
||||
transform: none
|
||||
|
||||
.file-entry-name
|
||||
font-size: 0.875rem
|
||||
|
||||
.file-entry-details
|
||||
font-size: 0.75rem
|
1
webui/src/common/components/FileBrowser/index.js
Normal file
1
webui/src/common/components/FileBrowser/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export {FileBrowser} from './FileBrowser.jsx';
|
419
webui/src/pages/Machines/MachineDetails.jsx
Normal file
419
webui/src/pages/Machines/MachineDetails.jsx
Normal file
@@ -0,0 +1,419 @@
|
||||
import React, {useState, useEffect, useContext} from 'react';
|
||||
import {useParams, useNavigate} from 'react-router-dom';
|
||||
import {UserContext} from '@/common/contexts/UserContext.jsx';
|
||||
import {useToast} from '@/common/contexts/ToastContext.jsx';
|
||||
import {getRequest} from '@/common/utils/RequestUtil.js';
|
||||
import Button from '@/common/components/Button';
|
||||
import Card, {CardHeader, CardBody} from '@/common/components/Card';
|
||||
import Badge from '@/common/components/Badge';
|
||||
import LoadingSpinner from '@/common/components/LoadingSpinner';
|
||||
import EmptyState from '@/common/components/EmptyState';
|
||||
import PageHeader from '@/common/components/PageHeader';
|
||||
import DetailItem, {DetailList} from '@/common/components/DetailItem';
|
||||
import {FileBrowser} from '@/common/components/FileBrowser';
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
ComputerTowerIcon,
|
||||
CameraIcon,
|
||||
HardDriveIcon,
|
||||
CalendarIcon,
|
||||
IdentificationCardIcon,
|
||||
DatabaseIcon,
|
||||
FolderIcon,
|
||||
CaretDownIcon,
|
||||
CaretRightIcon,
|
||||
CircleIcon,
|
||||
FolderOpenIcon
|
||||
} from '@phosphor-icons/react';
|
||||
import './machine-details.sass';
|
||||
|
||||
export const MachineDetails = () => {
|
||||
const {id} = useParams();
|
||||
const navigate = useNavigate();
|
||||
const {user: currentUser} = useContext(UserContext);
|
||||
const toast = useToast();
|
||||
|
||||
const [machine, setMachine] = useState(null);
|
||||
const [snapshots, setSnapshots] = useState([]);
|
||||
const [selectedSnapshot, setSelectedSnapshot] = useState(null);
|
||||
const [snapshotDetails, setSnapshotDetails] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [snapshotsLoading, setSnapshotsLoading] = useState(false);
|
||||
const [detailsLoading, setDetailsLoading] = useState(false);
|
||||
const [expandedDisks, setExpandedDisks] = useState(new Set());
|
||||
const [fileBrowserOpen, setFileBrowserOpen] = useState(false);
|
||||
const [selectedPartition, setSelectedPartition] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMachine();
|
||||
fetchSnapshots();
|
||||
}, [id]);
|
||||
|
||||
const fetchMachine = async () => {
|
||||
try {
|
||||
const response = await getRequest(`machines/${id}`);
|
||||
setMachine(response);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch machine:', error);
|
||||
toast.error('Failed to load machine details. Please try again.');
|
||||
navigate('/machines');
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSnapshots = async () => {
|
||||
try {
|
||||
setSnapshotsLoading(true);
|
||||
const response = await getRequest(`machines/${id}/snapshots`);
|
||||
setSnapshots(response);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch snapshots:', error);
|
||||
toast.error('Failed to load snapshots. Please try again.');
|
||||
} finally {
|
||||
setSnapshotsLoading(false);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchSnapshotDetails = async (snapshotId) => {
|
||||
try {
|
||||
setDetailsLoading(true);
|
||||
const response = await getRequest(`machines/${id}/snapshots/${snapshotId}`);
|
||||
setSnapshotDetails(response);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch snapshot details:', error);
|
||||
toast.error('Failed to load snapshot details. Please try again.');
|
||||
} finally {
|
||||
setDetailsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSnapshotClick = (snapshot) => {
|
||||
if (selectedSnapshot?.id === snapshot.id) {
|
||||
setSelectedSnapshot(null);
|
||||
setSnapshotDetails(null);
|
||||
} else {
|
||||
setSelectedSnapshot(snapshot);
|
||||
setSnapshotDetails(null);
|
||||
setExpandedDisks(new Set());
|
||||
fetchSnapshotDetails(snapshot.id);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDiskExpansion = (diskSerial) => {
|
||||
const newExpanded = new Set(expandedDisks);
|
||||
if (newExpanded.has(diskSerial)) {
|
||||
newExpanded.delete(diskSerial);
|
||||
} else {
|
||||
newExpanded.add(diskSerial);
|
||||
}
|
||||
setExpandedDisks(newExpanded);
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString + ' UTC').toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
const formatBytes = (bytes) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const formatUuid = (uuid) => {
|
||||
return uuid.substring(0, 8).toUpperCase();
|
||||
};
|
||||
|
||||
const getFsTypeColor = (fsType) => {
|
||||
switch (fsType.toLowerCase()) {
|
||||
case 'ntfs': return 'primary';
|
||||
case 'ext': return 'success';
|
||||
case 'fat32': return 'warning';
|
||||
default: return 'subtle';
|
||||
}
|
||||
};
|
||||
|
||||
const openFileBrowser = (partition, diskIndex, partitionIndex) => {
|
||||
setSelectedPartition({
|
||||
...partition,
|
||||
diskIndex,
|
||||
partitionIndex,
|
||||
globalPartitionIndex: diskIndex * 10 + partitionIndex // Simple calculation for now
|
||||
});
|
||||
setFileBrowserOpen(true);
|
||||
};
|
||||
|
||||
const closeFileBrowser = () => {
|
||||
setFileBrowserOpen(false);
|
||||
setSelectedPartition(null);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="content">
|
||||
<LoadingSpinner centered text="Loading machine details..."/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!machine) {
|
||||
return (
|
||||
<div className="content">
|
||||
<EmptyState
|
||||
icon={<ComputerTowerIcon size={48} weight="duotone"/>}
|
||||
title="Machine not found"
|
||||
description="The requested machine could not be found or you don't have access to it"
|
||||
action={
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<ArrowLeftIcon size={16}/>}
|
||||
onClick={() => navigate('/machines')}
|
||||
>
|
||||
Back to Machines
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="content">
|
||||
<PageHeader
|
||||
title={machine.name}
|
||||
subtitle={`Machine details and snapshots • ${formatUuid(machine.uuid)}`}
|
||||
actions={
|
||||
<Button
|
||||
variant="subtle"
|
||||
icon={<ArrowLeftIcon size={16}/>}
|
||||
onClick={() => navigate('/machines')}
|
||||
>
|
||||
Back to Machines
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Machine Information */}
|
||||
<Card className="machine-overview">
|
||||
<CardHeader>
|
||||
<div className="machine-header">
|
||||
<div className="machine-icon">
|
||||
<ComputerTowerIcon size={24} weight="duotone"/>
|
||||
</div>
|
||||
<div className="machine-meta">
|
||||
<h3 className="machine-title">{machine.name}</h3>
|
||||
<div className="machine-subtitle">
|
||||
<IdentificationCardIcon size={14}/>
|
||||
<span className="uuid-text">{machine.uuid}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<DetailList>
|
||||
<DetailItem icon={<CalendarIcon size={16}/>}>
|
||||
Registered: {formatDate(machine.created_at)}
|
||||
</DetailItem>
|
||||
<DetailItem icon={<CameraIcon size={16}/>}>
|
||||
Total Snapshots: {snapshots.length}
|
||||
</DetailItem>
|
||||
</DetailList>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* Snapshots Section */}
|
||||
<div className="snapshots-section">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">
|
||||
<CameraIcon size={20} weight="duotone"/>
|
||||
Snapshots
|
||||
</h2>
|
||||
{snapshotsLoading && <LoadingSpinner size="sm" text="Loading snapshots..."/>}
|
||||
</div>
|
||||
|
||||
{snapshots.length === 0 && !snapshotsLoading ? (
|
||||
<EmptyState
|
||||
icon={<CameraIcon size={48} weight="duotone"/>}
|
||||
title="No snapshots found"
|
||||
description="This machine doesn't have any snapshots yet. Snapshots will appear here once they are created."
|
||||
variant="subtle"
|
||||
/>
|
||||
) : (
|
||||
<div className="snapshots-list">
|
||||
{snapshots.map(snapshot => (
|
||||
<div key={snapshot.id} className="snapshot-container">
|
||||
<Card
|
||||
hover
|
||||
className={`snapshot-card ${selectedSnapshot?.id === snapshot.id ? 'snapshot-card--selected' : ''}`}
|
||||
onClick={() => handleSnapshotClick(snapshot)}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="snapshot-header">
|
||||
<div className="snapshot-icon">
|
||||
<CameraIcon size={20} weight="duotone"/>
|
||||
</div>
|
||||
<div className="snapshot-info">
|
||||
<div className="snapshot-id">
|
||||
Snapshot {snapshot.id.substring(0, 8)}
|
||||
</div>
|
||||
<div className="snapshot-date">
|
||||
{formatDate(snapshot.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="snapshot-hash">
|
||||
<span className="hash-label">Hash:</span>
|
||||
<span className="hash-value">{snapshot.snapshot_hash.substring(0, 12)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{/* Snapshot Details */}
|
||||
{selectedSnapshot?.id === snapshot.id && (
|
||||
<Card className="snapshot-details">
|
||||
<CardBody>
|
||||
{detailsLoading ? (
|
||||
<LoadingSpinner text="Loading snapshot details..."/>
|
||||
) : snapshotDetails ? (
|
||||
<div className="details-content">
|
||||
<div className="details-header">
|
||||
<h4 className="details-title">
|
||||
<DatabaseIcon size={18} weight="duotone"/>
|
||||
Disks and Partitions
|
||||
</h4>
|
||||
<Badge variant="subtle">
|
||||
{snapshotDetails.disks.length} disk{snapshotDetails.disks.length !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{snapshotDetails.disks.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={<HardDriveIcon size={32} weight="duotone"/>}
|
||||
title="No disk data"
|
||||
description="This snapshot doesn't contain any disk information"
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
<div className="disks-list">
|
||||
{snapshotDetails.disks.map((disk, index) => (
|
||||
<div key={disk.serial} className="disk-item">
|
||||
<div
|
||||
className="disk-header"
|
||||
onClick={() => toggleDiskExpansion(disk.serial)}
|
||||
>
|
||||
<div className="disk-toggle">
|
||||
{expandedDisks.has(disk.serial) ?
|
||||
<CaretDownIcon size={16}/> :
|
||||
<CaretRightIcon size={16}/>
|
||||
}
|
||||
</div>
|
||||
<div className="disk-icon">
|
||||
<HardDriveIcon size={18} weight="duotone"/>
|
||||
</div>
|
||||
<div className="disk-info">
|
||||
<div className="disk-title">
|
||||
Disk {index + 1}
|
||||
{disk.serial && (
|
||||
<span className="disk-serial">
|
||||
{disk.serial}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="disk-size">
|
||||
{formatBytes(disk.size_bytes)} • {disk.partitions.length} partition{disk.partitions.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandedDisks.has(disk.serial) && (
|
||||
<div className="partitions-list">
|
||||
{disk.partitions.length === 0 ? (
|
||||
<div className="no-partitions">
|
||||
<FolderIcon size={16} weight="duotone"/>
|
||||
<span>No partitions found</span>
|
||||
</div>
|
||||
) : (
|
||||
disk.partitions.map((partition, partIndex) => (
|
||||
<div key={partIndex} className="partition-item">
|
||||
<div className="partition-indicator">
|
||||
<CircleIcon size={8} weight="fill"/>
|
||||
</div>
|
||||
<div className="partition-info">
|
||||
<div className="partition-header">
|
||||
<span className="partition-title">
|
||||
Partition {partIndex + 1}
|
||||
</span>
|
||||
<div className="partition-badges">
|
||||
<Badge
|
||||
variant={getFsTypeColor(partition.fs_type)}
|
||||
size="sm"
|
||||
>
|
||||
{partition.fs_type.toUpperCase()}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
icon={<FolderOpenIcon size={14}/>}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openFileBrowser(partition, index, partIndex);
|
||||
}}
|
||||
>
|
||||
Browse Files
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="partition-details">
|
||||
<span>{formatBytes(partition.size_bytes)}</span>
|
||||
<span>•</span>
|
||||
<span>LBA {partition.start_lba} - {partition.end_lba}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={<DatabaseIcon size={32} weight="duotone"/>}
|
||||
title="Failed to load details"
|
||||
description="Could not load the snapshot details"
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File Browser Modal */}
|
||||
<FileBrowser
|
||||
isOpen={fileBrowserOpen}
|
||||
onClose={closeFileBrowser}
|
||||
machineId={id}
|
||||
snapshotId={selectedSnapshot?.id}
|
||||
partitionIndex={selectedPartition?.globalPartitionIndex}
|
||||
partitionInfo={selectedPartition}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
@@ -1,4 +1,5 @@
|
||||
import React, {useState, useEffect, useContext} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {UserContext} from '@/common/contexts/UserContext.jsx';
|
||||
import {useToast} from '@/common/contexts/ToastContext.jsx';
|
||||
import {getRequest, postRequest, deleteRequest} from '@/common/utils/RequestUtil.js';
|
||||
@@ -27,6 +28,7 @@ import './styles.sass';
|
||||
|
||||
export const Machines = () => {
|
||||
const {user: currentUser} = useContext(UserContext);
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const [machines, setMachines] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -194,6 +196,10 @@ export const Machines = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleMachineClick = (machineId) => {
|
||||
navigate(`/machines/${machineId}`);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="content">
|
||||
@@ -220,7 +226,12 @@ export const Machines = () => {
|
||||
|
||||
<Grid minWidth="400px">
|
||||
{machines.map(machine => (
|
||||
<Card key={machine.id} hover className="machine-card">
|
||||
<Card
|
||||
key={machine.id}
|
||||
hover
|
||||
className="machine-card"
|
||||
onClick={() => handleMachineClick(machine.id)}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="machine-card-header">
|
||||
<div className="machine-icon">
|
||||
@@ -238,7 +249,10 @@ export const Machines = () => {
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
icon={<QrCodeIcon size={14}/>}
|
||||
onClick={() => openProvisioningModal(machine)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openProvisioningModal(machine);
|
||||
}}
|
||||
>
|
||||
Code
|
||||
</Button>
|
||||
@@ -246,7 +260,10 @@ export const Machines = () => {
|
||||
variant="danger"
|
||||
size="sm"
|
||||
icon={<TrashIcon size={14}/>}
|
||||
onClick={() => handleDelete(machine.id, machine.name)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(machine.id, machine.name);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
|
@@ -1 +1,2 @@
|
||||
export {Machines as default} from './Machines.jsx';
|
||||
export {MachineDetails} from './MachineDetails.jsx';
|
||||
|
303
webui/src/pages/Machines/machine-details.sass
Normal file
303
webui/src/pages/Machines/machine-details.sass
Normal file
@@ -0,0 +1,303 @@
|
||||
// Machine Details Styles
|
||||
.machine-overview
|
||||
.machine-header
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 1rem
|
||||
|
||||
.machine-icon
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
width: 3rem
|
||||
height: 3rem
|
||||
background: rgba(15, 98, 254, 0.1)
|
||||
border-radius: var(--radius)
|
||||
color: var(--accent)
|
||||
flex-shrink: 0
|
||||
|
||||
.machine-meta
|
||||
flex: 1
|
||||
|
||||
.machine-title
|
||||
font-size: 1.25rem
|
||||
font-weight: 600
|
||||
color: var(--text)
|
||||
margin: 0 0 0.5rem 0
|
||||
|
||||
.machine-subtitle
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 0.25rem
|
||||
font-size: 0.875rem
|
||||
color: var(--text-dim)
|
||||
|
||||
.uuid-text
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Fira Mono', 'Droid Sans Mono', 'Courier New', monospace
|
||||
background: var(--bg-elev)
|
||||
padding: 0.125rem 0.375rem
|
||||
border-radius: var(--radius-sm)
|
||||
font-size: 0.75rem
|
||||
|
||||
// Snapshots Section
|
||||
.snapshots-section
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 1rem
|
||||
|
||||
.section-header
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: space-between
|
||||
|
||||
.section-title
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 0.5rem
|
||||
font-size: 1.125rem
|
||||
font-weight: 600
|
||||
color: var(--text)
|
||||
margin: 0
|
||||
|
||||
.snapshots-list
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 0.5rem
|
||||
|
||||
.snapshot-container
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 0.5rem
|
||||
|
||||
.snapshot-card
|
||||
cursor: pointer
|
||||
transition: all 0.2s ease
|
||||
border: 2px solid transparent
|
||||
|
||||
&:hover
|
||||
border-color: rgba(15, 98, 254, 0.3)
|
||||
|
||||
&--selected
|
||||
border-color: var(--accent)
|
||||
background: rgba(15, 98, 254, 0.04)
|
||||
|
||||
.snapshot-header
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 1rem
|
||||
width: 100%
|
||||
|
||||
.snapshot-icon
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
width: 2.5rem
|
||||
height: 2.5rem
|
||||
background: rgba(15, 98, 254, 0.1)
|
||||
border-radius: var(--radius)
|
||||
color: var(--accent)
|
||||
flex-shrink: 0
|
||||
|
||||
.snapshot-info
|
||||
flex: 1
|
||||
min-width: 0
|
||||
|
||||
.snapshot-id
|
||||
font-weight: 600
|
||||
color: var(--text)
|
||||
font-size: 0.875rem
|
||||
|
||||
.snapshot-date
|
||||
font-size: 0.75rem
|
||||
color: var(--text-dim)
|
||||
margin-top: 0.125rem
|
||||
|
||||
.snapshot-hash
|
||||
display: flex
|
||||
flex-direction: column
|
||||
align-items: flex-end
|
||||
gap: 0.125rem
|
||||
flex-shrink: 0
|
||||
|
||||
.hash-label
|
||||
font-size: 0.75rem
|
||||
color: var(--text-dim)
|
||||
|
||||
.hash-value
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Fira Mono', 'Droid Sans Mono', 'Courier New', monospace
|
||||
font-size: 0.75rem
|
||||
background: var(--bg-elev)
|
||||
padding: 0.125rem 0.375rem
|
||||
border-radius: var(--radius-sm)
|
||||
color: var(--text)
|
||||
|
||||
// Snapshot Details
|
||||
.snapshot-details
|
||||
margin-left: 1rem
|
||||
border-left: 3px solid rgba(15, 98, 254, 0.3)
|
||||
background: var(--bg-elev)
|
||||
|
||||
.details-content
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 1rem
|
||||
|
||||
.details-header
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: space-between
|
||||
|
||||
.details-title
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 0.5rem
|
||||
font-size: 1rem
|
||||
font-weight: 600
|
||||
color: var(--text)
|
||||
margin: 0
|
||||
|
||||
// Disks List
|
||||
.disks-list
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 0.75rem
|
||||
|
||||
.disk-item
|
||||
border: 1px solid var(--border)
|
||||
border-radius: var(--radius)
|
||||
overflow: hidden
|
||||
background: var(--bg-alt)
|
||||
|
||||
.disk-header
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 0.75rem
|
||||
padding: 0.75rem 1rem
|
||||
cursor: pointer
|
||||
transition: background-color 0.2s ease
|
||||
|
||||
&:hover
|
||||
background: var(--bg-elev)
|
||||
|
||||
.disk-toggle
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
color: var(--text-dim)
|
||||
flex-shrink: 0
|
||||
|
||||
.disk-icon
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
width: 2rem
|
||||
height: 2rem
|
||||
background: rgba(0, 67, 206, 0.1)
|
||||
border-radius: var(--radius-sm)
|
||||
color: #0043ce
|
||||
flex-shrink: 0
|
||||
|
||||
.disk-info
|
||||
flex: 1
|
||||
min-width: 0
|
||||
|
||||
.disk-title
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 0.5rem
|
||||
font-weight: 600
|
||||
color: var(--text)
|
||||
font-size: 0.875rem
|
||||
|
||||
.disk-serial
|
||||
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Fira Mono', 'Droid Sans Mono', 'Courier New', monospace
|
||||
font-size: 0.75rem
|
||||
color: var(--text-dim)
|
||||
background: var(--bg-elev)
|
||||
padding: 0.125rem 0.25rem
|
||||
border-radius: var(--radius-sm)
|
||||
|
||||
.disk-size
|
||||
font-size: 0.75rem
|
||||
color: var(--text-dim)
|
||||
margin-top: 0.125rem
|
||||
|
||||
// Partitions List
|
||||
.partitions-list
|
||||
background: var(--bg-elev)
|
||||
border-top: 1px solid var(--border)
|
||||
|
||||
.no-partitions
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
gap: 0.5rem
|
||||
padding: 1rem
|
||||
color: var(--text-dim)
|
||||
font-size: 0.875rem
|
||||
|
||||
.partition-item
|
||||
display: flex
|
||||
align-items: flex-start
|
||||
gap: 0.75rem
|
||||
padding: 0.75rem 1rem
|
||||
border-bottom: 1px solid rgba(223, 227, 232, 0.5)
|
||||
|
||||
&:last-child
|
||||
border-bottom: none
|
||||
|
||||
.partition-indicator
|
||||
display: flex
|
||||
align-items: center
|
||||
padding-top: 0.375rem
|
||||
color: var(--accent)
|
||||
flex-shrink: 0
|
||||
|
||||
.partition-info
|
||||
flex: 1
|
||||
min-width: 0
|
||||
|
||||
.partition-header
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: space-between
|
||||
gap: 0.5rem
|
||||
margin-bottom: 0.25rem
|
||||
|
||||
.partition-badges
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 0.5rem
|
||||
|
||||
.partition-title
|
||||
font-weight: 500
|
||||
color: var(--text)
|
||||
font-size: 0.875rem
|
||||
|
||||
.partition-details
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 0.5rem
|
||||
font-size: 0.75rem
|
||||
color: var(--text-dim)
|
||||
|
||||
// Responsive design
|
||||
@media (max-width: 768px)
|
||||
.snapshot-header
|
||||
flex-direction: column
|
||||
align-items: flex-start
|
||||
gap: 0.5rem
|
||||
|
||||
.snapshot-hash
|
||||
align-items: flex-start
|
||||
|
||||
.disk-header
|
||||
flex-wrap: wrap
|
||||
|
||||
.partition-header
|
||||
flex-direction: column
|
||||
align-items: flex-start
|
||||
gap: 0.25rem
|
||||
|
||||
.partition-details
|
||||
flex-wrap: wrap
|
@@ -1,4 +1,11 @@
|
||||
.machine-card
|
||||
cursor: pointer
|
||||
transition: all 0.2s ease
|
||||
|
||||
&:hover
|
||||
transform: translateY(-2px)
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1)
|
||||
|
||||
.machine-card-header
|
||||
display: flex
|
||||
align-items: flex-start
|
||||
@@ -44,6 +51,7 @@
|
||||
display: flex
|
||||
gap: 0.5rem
|
||||
flex-shrink: 0
|
||||
flex-wrap: wrap
|
||||
|
||||
.modal-description
|
||||
color: var(--color-text-muted)
|
||||
|
Reference in New Issue
Block a user