chore: V2 planning docs + template discovery
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ViewType, Host } from './types';
|
||||
import DashboardView from './components/DashboardView';
|
||||
import SftpView from './components/SftpView';
|
||||
import TerminalView from './components/TerminalView';
|
||||
import SnippetsView from './components/SnippetsView';
|
||||
import KeychainView from './components/KeychainView';
|
||||
import SettingsView from './components/SettingsView';
|
||||
import BriefOverlay from './components/BriefOverlay';
|
||||
|
||||
// Lucide icons
|
||||
import {
|
||||
Terminal,
|
||||
FolderSync,
|
||||
TerminalSquare,
|
||||
Code2,
|
||||
KeyRound,
|
||||
Settings2,
|
||||
FileQuestion,
|
||||
Cpu,
|
||||
ChevronRight,
|
||||
Layers,
|
||||
ArrowRightLeft,
|
||||
Crown
|
||||
} from 'lucide-react';
|
||||
|
||||
export default function App() {
|
||||
const [currentView, setCurrentView] = useState<ViewType>('hosts');
|
||||
const [isBriefOpen, setIsBriefOpen] = useState(false);
|
||||
|
||||
// Connection target host (instantiated via dashboard "Connect" clicks)
|
||||
const [connectedHost, setConnectedHost] = useState<string | null>(null);
|
||||
|
||||
// Hosts collection
|
||||
const [hosts, setHosts] = useState<Host[]>([
|
||||
{
|
||||
id: '1',
|
||||
name: 'production-api-01',
|
||||
ip: '10.0.1.201',
|
||||
os: 'Ubuntu 22.04',
|
||||
provider: 'AWS US-East',
|
||||
status: 'active',
|
||||
lastSeen: '2 hours ago',
|
||||
type: 'api'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'db-cluster-main',
|
||||
ip: '10.0.2.14',
|
||||
os: 'Debian 11',
|
||||
provider: 'GCP Cloud',
|
||||
status: 'active',
|
||||
lastSeen: '5 hours ago',
|
||||
type: 'db'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'edge-node-berlin',
|
||||
ip: '45.132.89.12',
|
||||
os: 'Alpine Linux',
|
||||
provider: 'Hetzner',
|
||||
status: 'active',
|
||||
lastSeen: '1 day ago',
|
||||
type: 'edge'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'web-frontend-02',
|
||||
ip: '75.101.44.8',
|
||||
os: 'Ubuntu 22.04',
|
||||
provider: 'AWS US-East',
|
||||
status: 'offline',
|
||||
lastSeen: '3 days ago',
|
||||
type: 'web'
|
||||
}
|
||||
]);
|
||||
|
||||
const handleConnectHost = (hostName: string) => {
|
||||
setConnectedHost(hostName);
|
||||
setCurrentView('terminal');
|
||||
};
|
||||
|
||||
const handleAddHost = (newHost: Host) => {
|
||||
setHosts(prev => [newHost, ...prev]);
|
||||
};
|
||||
|
||||
// Nav items helper
|
||||
const navItems = [
|
||||
{ id: 'hosts', label: 'Hosts Manager', icon: Cpu, desc: 'Active clusters & networks' },
|
||||
{ id: 'sftp', label: 'SFTP File Sync', icon: FolderSync, desc: 'Secure asset pipeline' },
|
||||
{ id: 'terminal', label: 'SSH Shell client', icon: TerminalSquare, desc: 'Interactive remote command shell' },
|
||||
{ id: 'snippets', label: 'Snippets Library', icon: Code2, desc: 'Reusable DevOps commands' },
|
||||
{ id: 'keychain', label: 'Keys & Keychain', icon: KeyRound, desc: 'SSH identity credentials' },
|
||||
{ id: 'settings', label: 'Preferences', icon: Settings2, desc: 'Workspace settings & account' }
|
||||
];
|
||||
|
||||
// Render view router helper
|
||||
const renderView = () => {
|
||||
switch (currentView) {
|
||||
case 'hosts':
|
||||
return (
|
||||
<DashboardView
|
||||
onConnectHost={handleConnectHost}
|
||||
hosts={hosts}
|
||||
onAddHost={handleAddHost}
|
||||
/>
|
||||
);
|
||||
case 'sftp':
|
||||
return <SftpView />;
|
||||
case 'terminal':
|
||||
return <TerminalView />;
|
||||
case 'snippets':
|
||||
return <SnippetsView />;
|
||||
case 'keychain':
|
||||
return <KeychainView />;
|
||||
case 'settings':
|
||||
return <SettingsView />;
|
||||
default:
|
||||
return (
|
||||
<DashboardView
|
||||
onConnectHost={handleConnectHost}
|
||||
hosts={hosts}
|
||||
onAddHost={handleAddHost}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface dot-grid flex overflow-hidden">
|
||||
|
||||
{/* Sidebar Frame */}
|
||||
<aside className="w-[260px] border-r border-outline-variant bg-white/75 backdrop-blur-md hidden md:flex flex-col justify-between shrink-0 z-10">
|
||||
|
||||
{/* Top Header Logo */}
|
||||
<div className="p-6 border-b border-outline-variant/30 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-primary flex items-center justify-center text-white shadow-lg shadow-primary/20">
|
||||
<Terminal className="w-5 h-5 animate-pulse" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-extrabold text-on-surface text-sm tracking-tight leading-none uppercase">HostKeeper</h1>
|
||||
<span className="text-[10px] text-outline font-bold tracking-widest mt-1 block leading-none">LUMINA TECH</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setIsBriefOpen(true)}
|
||||
className="p-1 hover:bg-primary-container/15 hover:text-primary rounded text-outline transition-colors cursor-pointer"
|
||||
title="Review System Brief"
|
||||
>
|
||||
<FileQuestion className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation list */}
|
||||
<nav className="flex-1 px-4 py-6 space-y-1 overflow-y-auto no-scrollbar">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = currentView === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setCurrentView(item.id as ViewType)}
|
||||
className={`w-full flex items-center gap-3.5 px-4 py-3 rounded-xl transition-all text-left border cursor-pointer ${isActive ? 'bg-primary text-white border-primary shadow-lg shadow-primary/10' : 'bg-transparent text-on-surface-variant hover:bg-surface-container-low border-transparent'}`}
|
||||
>
|
||||
<Icon className={`w-5 h-5 ${isActive ? 'text-white' : 'text-outline-variant group-hover:text-primary'}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-bold leading-tight">{item.label}</p>
|
||||
<p className={`text-[9px] truncate mt-0.5 leading-none ${isActive ? 'text-primary-fixed' : 'text-outline'}`}>
|
||||
{item.desc}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className={`w-3.5 h-3.5 ${isActive ? 'opacity-100 text-white' : 'opacity-0'}`} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* User profile footer drawer badge */}
|
||||
<div className="p-4 border-t border-outline-variant/40 bg-surface-container-low/30">
|
||||
<div className="flex items-center justify-between p-2 rounded-xl bg-white border border-outline-variant/50">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center font-black text-primary text-xs relative shrink-0">
|
||||
AR
|
||||
<span className="absolute right-0 bottom-0 w-2 h-2 rounded-full bg-secondary border border-white animate-pulse"></span>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-bold text-on-surface truncate">Alex Rivera</p>
|
||||
<p className="text-[9px] text-outline font-semibold uppercase tracking-wider">DevOps Lead</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="p-1 text-primary cursor-help" title="Pro Account Status">
|
||||
<Crown className="w-4 h-4 text-amber-500 fill-amber-500" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
|
||||
{/* Main workspace arena content panel */}
|
||||
<main className="flex-1 flex flex-col min-w-0 relative overflow-y-auto h-screen no-scrollbar">
|
||||
|
||||
{/* Top workspace action app bar */}
|
||||
<header className="px-4 sm:px-6 py-4 bg-white/60 backdrop-blur-sm border-b border-outline-variant flex items-center justify-between z-10 gap-3">
|
||||
<div className="flex items-center gap-2 sm:gap-4 min-w-0">
|
||||
<h2 className="text-xs sm:text-sm font-bold text-on-surface tracking-tight uppercase truncate">
|
||||
{navItems.find(i => i.id === currentView)?.label}
|
||||
</h2>
|
||||
{connectedHost && currentView === 'terminal' && (
|
||||
<span className="px-2 py-0.5 bg-primary-container/20 border border-primary/20 text-primary rounded-lg text-[9px] sm:text-[10px] font-mono font-bold animate-pulse truncate max-w-[120px] sm:max-w-none" title={`CONNECTED TO: ${connectedHost}`}>
|
||||
<span className="hidden sm:inline">CONNECTED TO: </span>{connectedHost}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 shrink-0">
|
||||
{/* Quick Brief activator */}
|
||||
<button
|
||||
onClick={() => setIsBriefOpen(true)}
|
||||
className="flex items-center gap-1.5 px-2.5 sm:px-3 py-1.5 bg-primary text-white hover:bg-primary-container hover:text-on-primary-container text-xs font-bold rounded-xl shadow-md transition-all cursor-pointer"
|
||||
>
|
||||
<FileQuestion className="w-4 h-4 animate-bounce" />
|
||||
<span className="hidden sm:inline">AI System Brief</span>
|
||||
<span className="sm:hidden">Brief</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* View render hub */}
|
||||
<div className="flex-1 bg-surface-container-low/10 overflow-y-auto pb-24 md:pb-12">
|
||||
{renderView()}
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
{/* Mobile Responsive Navigation Footer Bar */}
|
||||
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-white/95 backdrop-blur-md border-t border-outline-variant/60 flex justify-around py-2 px-1 z-[100] shadow-lg">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = currentView === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => setCurrentView(item.id as ViewType)}
|
||||
className={`flex flex-col items-center gap-1 px-2.5 py-1 rounded-xl transition-all cursor-pointer ${isActive ? 'text-primary' : 'text-outline hover:text-on-surface-variant'}`}
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
<span className="text-[9px] font-bold leading-none">{item.label.split(' ')[0]}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Floating System Brief Drawer Overlay */}
|
||||
<BriefOverlay isOpen={isBriefOpen} onClose={() => setIsBriefOpen(false)} />
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import React from 'react';
|
||||
import { X, Terminal, Cpu, Palette, Layout, Layers, Box, Code } from 'lucide-react';
|
||||
|
||||
interface BriefOverlayProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function BriefOverlay({ isOpen, onClose }: BriefOverlayProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm z-[200] flex justify-end transition-opacity duration-300">
|
||||
<div className="w-full max-w-2xl bg-white h-screen shadow-2xl flex flex-col overflow-hidden animate-slide-in">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-5 bg-primary text-white flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Terminal className="w-6 h-6 animate-pulse" />
|
||||
<div>
|
||||
<h2 className="text-xl font-bold tracking-tight">AI & Developer Brief</h2>
|
||||
<p className="text-xs text-primary-fixed opacity-90">Design System & Blueprint for HostKeeper</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 hover:bg-white/10 rounded-lg transition-colors cursor-pointer text-white"
|
||||
title="Close brief"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-8 custom-scrollbar">
|
||||
{/* Intro Guidance */}
|
||||
<div className="bg-primary-container/10 border border-primary/20 rounded-xl p-4 text-sm text-on-surface">
|
||||
<span className="font-bold text-primary block mb-1">🤖 AI-to-AI / Developer System Instructions</span>
|
||||
This documentation is built directly into the codebase to guide downstream development agents.
|
||||
Maintain the <strong className="text-primary">Bright-Tech / Lumina Terminal</strong> aesthetic. Avoid dark-mode-by-default stereotypes unless requested; focus on high-clarity slate cards, interactive neon accents, and crisp typography.
|
||||
</div>
|
||||
|
||||
{/* Section: Palette */}
|
||||
<section className="space-y-3">
|
||||
<h3 className="font-bold text-on-background flex items-center gap-2 text-base">
|
||||
<Palette className="w-5 h-5 text-primary" />
|
||||
Colors (Lumina System)
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
<div className="p-3 border border-outline-variant rounded-lg bg-surface-container-low text-xs">
|
||||
<div className="w-full h-8 rounded bg-[#0050cb] mb-1.5"></div>
|
||||
<p className="font-bold">Primary (Blue)</p>
|
||||
<code className="text-[10px] text-outline">#0050cb</code>
|
||||
</div>
|
||||
<div className="p-3 border border-outline-variant rounded-lg bg-surface-container-low text-xs">
|
||||
<div className="w-full h-8 rounded bg-[#006e2f] mb-1.5"></div>
|
||||
<p className="font-bold">Secondary (Green)</p>
|
||||
<code className="text-[10px] text-outline">#006e2f</code>
|
||||
</div>
|
||||
<div className="p-3 border border-outline-variant rounded-lg bg-surface-container-low text-xs">
|
||||
<div className="w-full h-8 rounded bg-[#7e23cc] mb-1.5"></div>
|
||||
<p className="font-bold">Tertiary (Purple)</p>
|
||||
<code className="text-[10px] text-outline">#7e23cc</code>
|
||||
</div>
|
||||
<div className="p-3 border border-outline-variant rounded-lg bg-surface-container-low text-xs">
|
||||
<div className="w-full h-8 rounded bg-[#f7f9fb] mb-1.5 border border-outline-variant/50"></div>
|
||||
<p className="font-bold">Surface Base</p>
|
||||
<code className="text-[10px] text-outline">#f7f9fb</code>
|
||||
</div>
|
||||
<div className="p-3 border border-outline-variant rounded-lg bg-surface-container-low text-xs">
|
||||
<div className="w-full h-8 rounded bg-white mb-1.5 border border-outline-variant/50"></div>
|
||||
<p className="font-bold">Container Lowest</p>
|
||||
<code className="text-[10px] text-outline">#ffffff</code>
|
||||
</div>
|
||||
<div className="p-3 border border-outline-variant rounded-lg bg-surface-container-low text-xs">
|
||||
<div className="w-full h-8 rounded bg-[#191c1e] mb-1.5"></div>
|
||||
<p className="font-bold">On Surface</p>
|
||||
<code className="text-[10px] text-outline">#191c1e</code>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Section: Typography */}
|
||||
<section className="space-y-3">
|
||||
<h3 className="font-bold text-on-background flex items-center gap-2 text-base">
|
||||
<Code className="w-5 h-5 text-primary" />
|
||||
Typography Specifications
|
||||
</h3>
|
||||
<div className="border border-outline-variant rounded-xl divide-y divide-outline-variant/50 overflow-hidden text-sm">
|
||||
<div className="p-4 bg-surface-container-lowest">
|
||||
<p className="font-bold text-xs text-outline mb-1 uppercase tracking-wider">UI Font Pairing (Inter)</p>
|
||||
<p className="font-sans text-lg font-bold">Inter Sans-Serif</p>
|
||||
<p className="text-xs text-on-surface-variant">Used for all lists, forms, dashboard metrics, setting menus, and high-contrast labels.</p>
|
||||
</div>
|
||||
<div className="p-4 bg-surface-container-lowest font-mono">
|
||||
<p className="font-bold text-xs text-outline mb-1 uppercase tracking-wider font-sans">Technical Font (JetBrains Mono)</p>
|
||||
<p className="text-base font-medium">JetBrains Mono</p>
|
||||
<p className="text-xs text-on-surface-variant font-sans mt-1">Used for terminal outputs, commands, snippets, directory paths, and code metadata.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Section: Layout & Spacing */}
|
||||
<section className="space-y-3">
|
||||
<h3 className="font-bold text-on-background flex items-center gap-2 text-base">
|
||||
<Layout className="w-5 h-5 text-primary" />
|
||||
Layout & Spacing Architecture
|
||||
</h3>
|
||||
<ul className="list-disc pl-5 space-y-2 text-sm text-on-surface-variant">
|
||||
<li>
|
||||
<strong className="text-on-background">The Dot Grid Texture:</strong> A 16px background repeating dot grid system (<code className="text-xs bg-surface-container px-1 rounded">radial-gradient(#E2E8F0 1.5px, transparent 1.5px)</code>) anchors the content, providing depth and mimicking physical engineering logs.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-on-background">Asymmetric Bento Sizing:</strong> Balance large focus modules (such as the active Terminal Session or SFTP local-remote streams) alongside secondary narrow utility sheets.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-on-background">Standardized Metrics:</strong> Sidebar width is exactly <code className="text-xs bg-surface-container px-1 rounded">260px</code>. Terminal containers use <code className="text-xs bg-surface-container px-1 rounded">20px</code> internal padding.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Section: Elevation & Glassmorphism */}
|
||||
<section className="space-y-3">
|
||||
<h3 className="font-bold text-on-background flex items-center gap-2 text-base">
|
||||
<Layers className="w-5 h-5 text-primary" />
|
||||
Depth & Glassmorphism Rules
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-xs">
|
||||
<div className="p-4 border border-outline-variant/60 rounded-xl space-y-1">
|
||||
<span className="font-bold text-primary text-xs uppercase tracking-wider block">Level 1: Translucency</span>
|
||||
<p className="text-on-surface-variant">Sidebars, Top App Bars, and overlay cards use <code className="text-[11px] bg-surface-container px-1 rounded">backdrop-blur-md</code> and semi-transparent backgrounds to overlay smoothly on background grid lines.</p>
|
||||
</div>
|
||||
<div className="p-4 border border-outline-variant/60 rounded-xl space-y-1">
|
||||
<span className="font-bold text-primary text-xs uppercase tracking-wider block">Level 2: Glowing States</span>
|
||||
<p className="text-on-surface-variant">Active focus objects should never look heavy. Apply accent borders (<code className="text-[11px] bg-surface-container px-1 rounded">border-primary</code>) and soft drop shadow glows of the accent color.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Section: Future Enhancements Blueprint */}
|
||||
<section className="space-y-3">
|
||||
<h3 className="font-bold text-on-background flex items-center gap-2 text-base">
|
||||
<Box className="w-5 h-5 text-primary" />
|
||||
AI Implementation Guidelines
|
||||
</h3>
|
||||
<div className="p-4 bg-surface-container-low rounded-xl text-xs space-y-2">
|
||||
<p>When modifying this code or adding database handlers:</p>
|
||||
<ol className="list-decimal pl-4 space-y-1.5 text-on-surface-variant">
|
||||
<li>Always maintain class structures using state routers instead of traditional pages for fluid interactions.</li>
|
||||
<li>Import standard icons only from <code className="text-xs font-mono text-primary font-bold">lucide-react</code>. Do not write custom inline SVG structures.</li>
|
||||
<li>Ensure interactive switches use the custom <code className="text-xs font-mono">checked</code> properties with transition toggles.</li>
|
||||
<li>For terminal commands, enhance the custom parser inside <code className="text-xs font-mono">TerminalView.tsx</code> to handle rich multi-line logs.</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-outline-variant bg-surface-container-low flex justify-between items-center text-xs text-outline font-medium">
|
||||
<span>Lumina Technical Blueprint v1.0</span>
|
||||
<span>Google AI Studio Build</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Host, BackgroundTransfer } from '../types';
|
||||
import {
|
||||
Server,
|
||||
Database,
|
||||
Cpu,
|
||||
ArrowRight,
|
||||
Search,
|
||||
Plus,
|
||||
LayoutGrid,
|
||||
List,
|
||||
AlertTriangle,
|
||||
TrendingUp,
|
||||
CheckCircle,
|
||||
FileCheck,
|
||||
Zap,
|
||||
Globe,
|
||||
Monitor,
|
||||
Laptop
|
||||
} from 'lucide-react';
|
||||
|
||||
interface DashboardViewProps {
|
||||
onConnectHost: (hostName: string) => void;
|
||||
hosts: Host[];
|
||||
onAddHost: (host: Host) => void;
|
||||
}
|
||||
|
||||
export default function DashboardView({ onConnectHost, hosts, onAddHost }: DashboardViewProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'offline'>('all');
|
||||
|
||||
// Quick manual host addition modal/state
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [newHostName, setNewHostName] = useState('');
|
||||
const [newHostIp, setNewHostIp] = useState('');
|
||||
const [newHostOs, setNewHostOs] = useState<'Ubuntu 22.04' | 'Debian 11' | 'Windows Server' | 'macOS Ventura' | 'CentOS 7' | 'Alpine Linux'>('Ubuntu 22.04');
|
||||
const [newHostProvider, setNewHostProvider] = useState<'AWS US-East' | 'GCP Cloud' | 'Hetzner' | 'Vercel Proxy' | 'Local Docker' | 'Bare Metal'>('AWS US-East');
|
||||
const [newHostType, setNewHostType] = useState<'api' | 'db' | 'edge' | 'web' | 'server'>('api');
|
||||
|
||||
// Background transfer list
|
||||
const [transfers, setTransfers] = useState<BackgroundTransfer[]>([
|
||||
{
|
||||
id: '1',
|
||||
fileName: 'backup_v4.tar.gz',
|
||||
source: 'production-api-01',
|
||||
destination: 'Local Machine',
|
||||
progress: 82,
|
||||
speed: '12.4 MB/s',
|
||||
type: 'download'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
fileName: 'assets_deploy.zip',
|
||||
source: 'Local Storage',
|
||||
destination: 'web-frontend-02',
|
||||
progress: 35,
|
||||
speed: '4.8 MB/s',
|
||||
type: 'upload'
|
||||
}
|
||||
]);
|
||||
|
||||
const handleCreateHost = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newHostName || !newHostIp) return;
|
||||
|
||||
const newHost: Host = {
|
||||
id: Date.now().toString(),
|
||||
name: newHostName.toLowerCase().replace(/\s+/g, '-'),
|
||||
ip: newHostIp,
|
||||
os: newHostOs,
|
||||
provider: newHostProvider,
|
||||
status: 'active',
|
||||
lastSeen: 'Just now',
|
||||
type: newHostType
|
||||
};
|
||||
|
||||
onAddHost(newHost);
|
||||
setShowAddModal(false);
|
||||
setNewHostName('');
|
||||
setNewHostIp('');
|
||||
};
|
||||
|
||||
const filteredHosts = hosts.filter(host => {
|
||||
const matchesSearch = host.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
host.ip.includes(searchQuery) ||
|
||||
host.provider.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchesStatus = statusFilter === 'all' || host.status === statusFilter;
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
const getOsIcon = (os: string) => {
|
||||
switch (os) {
|
||||
case 'macOS Ventura': return <Laptop className="w-5 h-5" />;
|
||||
case 'Windows Server': return <Monitor className="w-5 h-5" />;
|
||||
default: return <Database className="w-5 h-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 md:p-8 max-w-7xl mx-auto space-y-8 animate-fade-in">
|
||||
{/* Top Banner Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-6">
|
||||
{/* Left Stats Block */}
|
||||
<div className="md:col-span-8 p-6 rounded-2xl bg-white border border-outline-variant shadow-sm flex items-center justify-between overflow-hidden relative group">
|
||||
<div className="relative z-10 space-y-2">
|
||||
<h3 className="text-on-surface-variant font-bold text-xs uppercase tracking-wider">Active Connections</h3>
|
||||
<div className="flex items-end gap-3">
|
||||
<span className="text-4xl font-black text-primary">12</span>
|
||||
<span className="text-secondary font-bold text-sm mb-1 flex items-center bg-secondary-container/20 px-2 py-0.5 rounded-full">
|
||||
<TrendingUp className="w-4 h-4 mr-1" />
|
||||
+2 today
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-on-surface-variant">Global performance metrics sync integrity active.</p>
|
||||
</div>
|
||||
{/* Accent Grid Glow lines */}
|
||||
<div className="absolute right-0 top-0 w-1/3 h-full bg-gradient-to-l from-primary/5 to-transparent pointer-events-none"></div>
|
||||
|
||||
<div className="flex gap-2 items-end h-16 shrink-0">
|
||||
<div className="w-2.5 h-10 bg-primary/20 rounded-full animate-pulse"></div>
|
||||
<div className="w-2.5 h-14 bg-primary/40 rounded-full animate-pulse delay-75"></div>
|
||||
<div className="w-2.5 h-8 bg-primary/10 rounded-full animate-pulse delay-150"></div>
|
||||
<div className="w-2.5 h-16 bg-primary/60 rounded-full animate-pulse delay-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Stats Block - Promotion Card */}
|
||||
<div className="md:col-span-4 p-6 rounded-2xl bg-primary text-on-primary shadow-xl shadow-primary/15 flex flex-col justify-between relative overflow-hidden">
|
||||
<div className="relative z-10">
|
||||
<Zap className="w-8 h-8 opacity-90 text-white mb-2" />
|
||||
<p className="font-bold text-lg leading-tight">Fastest Access Enabled</p>
|
||||
</div>
|
||||
<p className="text-xs opacity-85 mt-2 relative z-10">
|
||||
Smart routing engine successfully reduces terminal latency by <span className="font-bold">24ms</span> across European and Asian clusters.
|
||||
</p>
|
||||
<div className="absolute -right-6 -bottom-6 opacity-10">
|
||||
<Zap className="w-32 h-32 rotate-12" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid Filter and Controls Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-outline-variant/30 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-lg font-bold text-on-surface">Recent Hosts</h3>
|
||||
<div className="flex bg-surface-container rounded-lg p-0.5 text-xs font-semibold text-outline-variant border border-outline-variant/20">
|
||||
<button
|
||||
onClick={() => setStatusFilter('all')}
|
||||
className={`px-2.5 py-1 rounded-md transition-all ${statusFilter === 'all' ? 'bg-white text-primary shadow-sm' : 'text-on-surface-variant hover:text-on-surface'}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('active')}
|
||||
className={`px-2.5 py-1 rounded-md transition-all ${statusFilter === 'active' ? 'bg-white text-primary shadow-sm' : 'text-on-surface-variant hover:text-on-surface'}`}
|
||||
>
|
||||
Active
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('offline')}
|
||||
className={`px-2.5 py-1 rounded-md transition-all ${statusFilter === 'offline' ? 'bg-white text-primary shadow-sm' : 'text-on-surface-variant hover:text-on-surface'}`}
|
||||
>
|
||||
Offline
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Inline Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-outline w-4 h-4" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search hosts..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 pr-4 py-1.5 rounded-full bg-surface-container-low border border-outline-variant/30 text-sm focus:border-primary focus:bg-white focus:outline-none focus:ring-1 focus:ring-primary w-48 sm:w-60 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex border border-outline-variant/50 rounded-lg p-0.5 bg-white">
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`p-1.5 rounded ${viewMode === 'grid' ? 'bg-surface-container text-primary' : 'text-on-surface-variant'}`}
|
||||
title="Grid View"
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`p-1.5 rounded ${viewMode === 'list' ? 'bg-surface-container text-primary' : 'text-on-surface-variant'}`}
|
||||
title="List View"
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid vs List View hosts container */}
|
||||
{viewMode === 'grid' ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredHosts.map((host) => (
|
||||
<div
|
||||
key={host.id}
|
||||
className="group bg-white p-5 rounded-2xl border border-outline-variant hover:border-primary hover:shadow-lg transition-all cursor-pointer relative overflow-hidden"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="w-11 h-11 rounded-xl bg-surface-container-low flex items-center justify-center text-primary border border-outline-variant/20 group-hover:bg-primary-container/10 group-hover:border-primary/20 transition-colors">
|
||||
{getOsIcon(host.os)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`w-2 h-2 rounded-full ${host.status === 'active' ? 'bg-secondary animate-pulse' : 'bg-outline'}`}></span>
|
||||
<span className={`text-[10px] font-bold uppercase tracking-wider ${host.status === 'active' ? 'text-secondary' : 'text-outline'}`}>
|
||||
{host.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 className="font-bold text-on-surface group-hover:text-primary transition-colors text-base truncate">{host.name}</h4>
|
||||
<p className="font-mono text-xs text-outline mb-4">{host.ip}</p>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5 mb-4">
|
||||
<span className="px-2 py-0.5 bg-surface-container-low rounded text-[10px] font-medium text-on-surface-variant">
|
||||
{host.os}
|
||||
</span>
|
||||
<span className="px-2 py-0.5 bg-surface-container-low rounded text-[10px] font-medium text-on-surface-variant">
|
||||
{host.provider}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-outline-variant/30 flex justify-between items-center text-xs">
|
||||
<span className="text-outline italic">Last seen {host.lastSeen}</span>
|
||||
<button
|
||||
onClick={() => onConnectHost(host.name)}
|
||||
className="bg-primary text-white hover:bg-primary-container hover:text-on-primary-container font-bold text-xs py-1.5 px-3 rounded-lg transition-all"
|
||||
>
|
||||
Connect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add Host Button Placeholder */}
|
||||
<div
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="group border-2 border-dashed border-outline-variant hover:border-primary hover:bg-primary/5 rounded-2xl flex flex-col items-center justify-center p-8 transition-all cursor-pointer text-center text-outline hover:text-primary"
|
||||
>
|
||||
<Plus className="w-8 h-8 mb-2 group-active:scale-90 transition-transform" />
|
||||
<span className="font-bold text-sm text-on-surface-variant group-hover:text-primary">Add New Host</span>
|
||||
<p className="text-[10px] uppercase tracking-widest mt-1 opacity-70">Manual Config or Discovery</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* List View Mode */
|
||||
<div className="bg-white border border-outline-variant rounded-2xl overflow-hidden shadow-sm divide-y divide-outline-variant/30">
|
||||
{filteredHosts.map((host) => (
|
||||
<div
|
||||
key={host.id}
|
||||
className="flex items-center justify-between p-4 hover:bg-primary/5 transition-colors cursor-pointer group"
|
||||
>
|
||||
<div className="flex items-center gap-4 min-w-0 flex-1">
|
||||
<div className="w-10 h-10 rounded-lg bg-surface-container-low flex items-center justify-center text-primary shrink-0 border border-outline-variant/20">
|
||||
{getOsIcon(host.os)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 sm:grid sm:grid-cols-3 sm:gap-4 items-center">
|
||||
<div>
|
||||
<h4 className="font-bold text-on-surface group-hover:text-primary transition-colors text-sm truncate">{host.name}</h4>
|
||||
<span className="font-mono text-xs text-outline">{host.ip}</span>
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<p className="text-xs text-on-surface-variant font-medium">{host.os}</p>
|
||||
<p className="text-[10px] text-outline">{host.provider}</p>
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<span className="text-xs text-outline italic">Seen {host.lastSeen}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 ml-4 shrink-0">
|
||||
<span className={`px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider flex items-center gap-1 ${host.status === 'active' ? 'bg-secondary-container/20 text-secondary' : 'bg-surface-container text-outline'}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${host.status === 'active' ? 'bg-secondary animate-pulse' : 'bg-outline'}`}></span>
|
||||
{host.status}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onConnectHost(host.name)}
|
||||
className="bg-primary text-white hover:bg-primary-container hover:text-on-primary-container font-bold text-xs py-1.5 px-4 rounded-lg transition-all"
|
||||
>
|
||||
Connect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="p-4 hover:bg-primary/5 transition-colors cursor-pointer flex items-center justify-center gap-2 text-outline hover:text-primary font-bold text-sm"
|
||||
>
|
||||
<Plus className="w-5 h-5" /> Add New Host
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transfer Activity - Asymmetric Bento footer panel */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<h3 className="text-base font-bold text-on-surface tracking-tight shrink-0">Background Transfers</h3>
|
||||
<div className="flex-1 h-px bg-outline-variant/30"></div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/50 backdrop-blur-sm border border-outline-variant rounded-2xl overflow-hidden shadow-sm">
|
||||
<div className="p-4 border-b border-outline-variant flex items-center justify-between bg-surface-container-low">
|
||||
<div className="flex gap-12 text-xs font-bold text-on-surface-variant uppercase tracking-tight">
|
||||
<span>Source & File</span>
|
||||
<span>Destination</span>
|
||||
</div>
|
||||
<span className="text-xs font-bold text-on-surface-variant uppercase tracking-tight">Progress</span>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-outline-variant/30 bg-white">
|
||||
{transfers.map((item) => (
|
||||
<div key={item.id} className="p-4 flex flex-col sm:flex-row sm:items-center justify-between gap-4 hover:bg-primary/5 transition-colors">
|
||||
<div className="flex items-center gap-4 min-w-0 flex-1">
|
||||
<div className={`p-2 rounded-lg ${item.type === 'download' ? 'bg-tertiary/10 text-tertiary' : 'bg-primary/10 text-primary'}`}>
|
||||
<FileCheck className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-bold truncate text-on-surface">{item.fileName}</p>
|
||||
<span className="text-[10px] font-mono text-outline block">{item.source}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-on-surface-variant text-xs">
|
||||
<ArrowRight className="w-4 h-4 text-outline" />
|
||||
<span className="truncate">{item.destination}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full sm:w-48 shrink-0">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span className="text-[10px] font-bold text-secondary">{item.progress}%</span>
|
||||
<span className="text-[10px] text-outline font-mono">{item.speed}</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full bg-surface-container-high rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-secondary rounded-full transition-all duration-500 shadow-[0_0_8px_rgba(0,110,47,0.3)]"
|
||||
style={{ width: `${item.progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Manual Add Host Modal */}
|
||||
{showAddModal && (
|
||||
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm z-[150] flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-2xl max-w-md w-full border border-outline-variant shadow-2xl overflow-hidden p-6 space-y-4 animate-scale-up">
|
||||
<div className="flex justify-between items-center border-b border-outline-variant/30 pb-3">
|
||||
<h3 className="font-bold text-lg text-on-surface">Add Host Credentials</h3>
|
||||
<button
|
||||
onClick={() => setShowAddModal(false)}
|
||||
className="text-outline hover:text-primary cursor-pointer p-1 rounded-lg"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreateHost} className="space-y-4 text-sm">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-bold text-on-surface-variant">HOST IDENTIFIER</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="e.g. prod-db-replica"
|
||||
value={newHostName}
|
||||
onChange={(e) => setNewHostName(e.target.value)}
|
||||
className="w-full bg-surface-container-low border border-outline-variant/40 rounded-lg py-2.5 px-4 focus:ring-2 focus:ring-primary/20 focus:border-primary focus:outline-none transition-all font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-bold text-on-surface-variant">IP ADDRESS / DOMAIN</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="e.g. 10.0.12.19"
|
||||
value={newHostIp}
|
||||
onChange={(e) => setNewHostIp(e.target.value)}
|
||||
className="w-full bg-surface-container-low border border-outline-variant/40 rounded-lg py-2.5 px-4 focus:ring-2 focus:ring-primary/20 focus:border-primary focus:outline-none transition-all font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-bold text-on-surface-variant">OPERATING SYSTEM</label>
|
||||
<select
|
||||
value={newHostOs}
|
||||
onChange={(e) => setNewHostOs(e.target.value as any)}
|
||||
className="bg-surface-container-low border border-outline-variant/40 rounded-lg py-2 px-3 focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="Ubuntu 22.04">Ubuntu 22.04</option>
|
||||
<option value="Debian 11">Debian 11</option>
|
||||
<option value="Alpine Linux">Alpine Linux</option>
|
||||
<option value="Windows Server">Windows Server</option>
|
||||
<option value="macOS Ventura">macOS Ventura</option>
|
||||
<option value="CentOS 7">CentOS 7</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-bold text-on-surface-variant">PROVIDER</label>
|
||||
<select
|
||||
value={newHostProvider}
|
||||
onChange={(e) => setNewHostProvider(e.target.value as any)}
|
||||
className="bg-surface-container-low border border-outline-variant/40 rounded-lg py-2 px-3 focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="AWS US-East">AWS Cloud</option>
|
||||
<option value="GCP Cloud">GCP Cloud</option>
|
||||
<option value="Hetzner">Hetzner</option>
|
||||
<option value="Vercel Proxy">Vercel</option>
|
||||
<option value="Local Docker">Docker</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-bold text-on-surface-variant">HOST PURPOSE / TYPE</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{['api', 'db', 'web'].map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setNewHostType(t as any)}
|
||||
className={`py-2 px-3 border rounded-lg font-bold text-xs uppercase tracking-wider transition-all ${newHostType === t ? 'bg-primary text-white border-primary' : 'bg-surface-container-low border-outline-variant/40 text-on-surface-variant hover:bg-surface-container'}`}
|
||||
>
|
||||
{t} Node
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddModal(false)}
|
||||
className="px-4 py-2 text-xs font-bold text-outline hover:text-on-surface"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-primary text-white py-2 px-5 rounded-lg font-bold text-xs shadow-md hover:bg-primary-container hover:text-on-primary-container transition-all"
|
||||
>
|
||||
Add Host
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
import React, { useState } from 'react';
|
||||
import { KeychainItem } from '../types';
|
||||
import { Shield, Key, Lock, Eye, EyeOff, Copy, Check, Plus, Search, Trash2, CheckCircle2, AlertTriangle, Info, HelpCircle } from 'lucide-react';
|
||||
|
||||
export default function KeychainView() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [revealPassId, setRevealPassId] = useState<string | null>(null);
|
||||
|
||||
// Keychain credentials collection
|
||||
const [keychain, setKeychain] = useState<KeychainItem[]>([
|
||||
{
|
||||
id: '1',
|
||||
name: 'id_ed25519_alex',
|
||||
type: 'ED25519',
|
||||
user: 'root',
|
||||
lastUsed: '2 hours ago',
|
||||
strength: 'secure',
|
||||
fingerprint: 'SHA256:7mP9K9+fVj5bW0vQ8zD1y2u3t4m5n6p7q8r9s0v1w2x',
|
||||
passphrase: 'supersecret_argon2_hashed',
|
||||
connectedHosts: ['production-api-01', 'edge-node-berlin'],
|
||||
keyFile: '— Private ED25519 Key file loaded —'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'aws_prod_deploy_secret',
|
||||
type: 'AWS Key',
|
||||
user: 'deploy-agent',
|
||||
lastUsed: '1 day ago',
|
||||
strength: 'secure',
|
||||
fingerprint: 'SHA256:8kU3A4+gYg2bS1vT8zP1q2e3r4t5y6u7i8o9p0a1s2d',
|
||||
passphrase: 'aws_access_key_id_and_secret',
|
||||
connectedHosts: ['web-frontend-02'],
|
||||
keyFile: '— AWS Credentials payload —'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'root_pg_prod',
|
||||
type: 'PASSWORD',
|
||||
user: 'postgres',
|
||||
lastUsed: '5 hours ago',
|
||||
strength: 'moderate',
|
||||
passphrase: 'pg_db_master_password_2026',
|
||||
connectedHosts: ['db-cluster-main'],
|
||||
keyFile: 'Password token'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'legacy_rsa_key',
|
||||
type: 'SSH RSA',
|
||||
user: 'admin',
|
||||
lastUsed: '3 weeks ago',
|
||||
strength: 'weak',
|
||||
fingerprint: 'SHA256:legacy_md5_fingerprint_block',
|
||||
passphrase: 'pass',
|
||||
connectedHosts: [],
|
||||
keyFile: '— 1024-bit Legacy SSH Keyfile —'
|
||||
}
|
||||
]);
|
||||
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newType, setNewType] = useState<'SSH RSA' | 'PASSWORD' | 'ED25519' | 'Bearer Token' | 'AWS Key'>('ED25519');
|
||||
const [newUser, setNewUser] = useState('');
|
||||
const [newPass, setNewPass] = useState('');
|
||||
|
||||
const triggerCopy = (id: string, text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopiedId(id);
|
||||
setTimeout(() => {
|
||||
setCopiedId(null);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const handleCreateCredential = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newName || !newUser) return;
|
||||
|
||||
const strengthVal = newPass.length > 12 ? 'secure' : newPass.length > 7 ? 'moderate' : 'weak';
|
||||
|
||||
const item: KeychainItem = {
|
||||
id: Date.now().toString(),
|
||||
name: newName,
|
||||
type: newType,
|
||||
user: newUser,
|
||||
lastUsed: 'Just now',
|
||||
strength: strengthVal,
|
||||
passphrase: newPass,
|
||||
connectedHosts: [],
|
||||
keyFile: newType.includes('Key') || newType.includes('ED25519') ? '— Custom key payload generated —' : 'Password credential'
|
||||
};
|
||||
|
||||
setKeychain([item, ...keychain]);
|
||||
setShowAddModal(false);
|
||||
setNewName('');
|
||||
setNewUser('');
|
||||
setNewPass('');
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setKeychain(prev => prev.filter(k => k.id !== id));
|
||||
};
|
||||
|
||||
const filteredKeychain = keychain.filter(k =>
|
||||
k.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
k.user.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
k.type.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const getStrengthBadge = (strength: string) => {
|
||||
switch (strength) {
|
||||
case 'secure':
|
||||
return (
|
||||
<span className="shrink-0 px-2.5 py-0.5 bg-secondary-container/20 text-secondary border border-secondary/20 rounded-full text-[10px] font-bold uppercase tracking-wider flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-secondary" />
|
||||
SECURE
|
||||
</span>
|
||||
);
|
||||
case 'moderate':
|
||||
return (
|
||||
<span className="shrink-0 px-2.5 py-0.5 bg-[#f0dbff] text-tertiary border border-tertiary/20 rounded-full text-[10px] font-bold uppercase tracking-wider flex items-center gap-1">
|
||||
<Info className="w-3.5 h-3.5 text-tertiary" />
|
||||
MODERATE
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<span className="shrink-0 px-2.5 py-0.5 bg-error-container/20 text-error border border-error/20 rounded-full text-[10px] font-bold uppercase tracking-wider flex items-center gap-1">
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-error animate-bounce" />
|
||||
WEAK
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 md:p-8 max-w-7xl mx-auto space-y-8 animate-fade-in">
|
||||
|
||||
{/* Header Panel */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-outline-variant/30 pb-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-on-surface tracking-tight">Keychain & Credentials</h2>
|
||||
<p className="text-xs text-on-surface-variant mt-0.5">Secure sandbox containing cluster credentials, private SSH keys, and tokens</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-outline w-4 h-4" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search credentials..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 pr-4 py-1.5 rounded-full bg-surface-container-low border border-outline-variant/30 text-sm focus:border-primary focus:bg-white focus:outline-none focus:ring-1 focus:ring-primary w-48 sm:w-60 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="flex items-center gap-1.5 bg-primary text-white hover:bg-primary-container hover:text-on-primary-container font-bold text-xs py-2 px-4 rounded-xl shadow-md transition-all cursor-pointer"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid List */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{filteredKeychain.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="bg-white p-5 rounded-2xl border border-outline-variant hover:border-primary hover:shadow-lg transition-all flex flex-col justify-between space-y-4"
|
||||
>
|
||||
<div className="flex justify-between items-start gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-surface-container-low border border-outline-variant/20 flex items-center justify-center text-primary">
|
||||
<Shield className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-on-surface text-base truncate max-w-[180px]">{item.name}</h3>
|
||||
<span className="text-[10px] font-mono text-outline uppercase tracking-wider">{item.type}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{getStrengthBadge(item.strength)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="flex justify-between items-center py-1 border-b border-outline-variant/10">
|
||||
<span className="text-outline font-semibold uppercase tracking-wider text-[10px]">User Login</span>
|
||||
<span className="font-mono text-on-surface font-semibold">{item.user}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1 border-b border-outline-variant/10">
|
||||
<span className="text-outline font-semibold uppercase tracking-wider text-[10px]">Credential Secrets</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-on-surface-variant">
|
||||
{revealPassId === item.id ? item.passphrase : '••••••••••••••••'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setRevealPassId(revealPassId === item.id ? null : item.id)}
|
||||
className="p-1 hover:bg-surface-container rounded text-outline transition-colors"
|
||||
title={revealPassId === item.id ? "Hide password" : "Show password"}
|
||||
>
|
||||
{revealPassId === item.id ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => triggerCopy(item.id, item.passphrase || '')}
|
||||
className="p-1 hover:bg-surface-container rounded text-outline transition-colors"
|
||||
title="Copy secret"
|
||||
>
|
||||
{copiedId === item.id ? <Check className="w-3.5 h-3.5 text-secondary" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.fingerprint && (
|
||||
<div className="py-1">
|
||||
<span className="text-outline font-semibold uppercase tracking-wider text-[10px] block mb-0.5">SHA256 FINGERPRINT</span>
|
||||
<code className="text-[10px] text-on-surface-variant font-mono block break-all bg-surface-container-low p-2 rounded border border-outline-variant/20 select-all">{item.fingerprint}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-outline-variant/30 flex flex-col sm:flex-row sm:items-center justify-between gap-3 text-[10px] text-outline">
|
||||
<span className="shrink-0">Last used {item.lastUsed}</span>
|
||||
<div className="flex items-center justify-between sm:justify-end gap-2 flex-1 min-w-0 w-full">
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1 justify-start sm:justify-end">
|
||||
<span className="text-outline shrink-0 font-bold">Hosts:</span>
|
||||
{item.connectedHosts && item.connectedHosts.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1 max-h-[44px] overflow-y-auto no-scrollbar justify-start sm:justify-end">
|
||||
{item.connectedHosts.map((host) => (
|
||||
<span
|
||||
key={host}
|
||||
className="bg-primary-container/15 text-primary px-1.5 py-0.5 rounded font-mono text-[9px] hover:bg-primary/10 transition-colors shrink-0"
|
||||
title={host}
|
||||
>
|
||||
{host}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-outline italic">None mapped</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDelete(item.id)}
|
||||
className="p-1 hover:bg-error-container hover:text-error rounded text-outline transition-colors shrink-0 cursor-pointer ml-1"
|
||||
title="Remove credential"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Security Advisory Bento */}
|
||||
<div className="p-5 border border-outline-variant/60 rounded-2xl bg-white/40 flex items-start gap-4 text-xs text-on-surface">
|
||||
<Info className="w-5 h-5 text-primary shrink-0 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-bold text-on-surface">HostKeeper Security Policy & Local Sandbox</h4>
|
||||
<p className="text-on-surface-variant leading-relaxed">
|
||||
Keys are parsed locally inside your isolated browser environment and transmitted strictly within cryptographically verified SSH tunnels. Passphrases are hashed with Argon2id instantly and never touch intermediate telemetry systems.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Credential Modal */}
|
||||
{showAddModal && (
|
||||
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm z-[150] flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-2xl max-w-md w-full border border-outline-variant shadow-2xl p-6 space-y-4 animate-scale-up">
|
||||
<div className="flex justify-between items-center border-b border-outline-variant/30 pb-3">
|
||||
<h3 className="font-bold text-lg text-on-surface">Register Secure Credential</h3>
|
||||
<button
|
||||
onClick={() => setShowAddModal(false)}
|
||||
className="text-outline hover:text-primary cursor-pointer p-1 rounded"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreateCredential} className="space-y-4 text-sm">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-bold text-on-surface-variant">CREDENTIAL NAME / LABEL</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="e.g. key_aws_alex"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
className="w-full bg-surface-container-low border border-outline-variant/40 rounded-lg py-2.5 px-4 focus:ring-2 focus:ring-primary/20 focus:border-primary focus:outline-none transition-all font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-bold text-on-surface-variant">CREDENTIAL TYPE</label>
|
||||
<select
|
||||
value={newType}
|
||||
onChange={(e) => setNewType(e.target.value as any)}
|
||||
className="bg-surface-container-low border border-outline-variant/40 rounded-lg py-2.5 px-3 focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="ED25519">ED25519 (Modern)</option>
|
||||
<option value="SSH RSA">SSH RSA (Legacy)</option>
|
||||
<option value="PASSWORD">Master Password</option>
|
||||
<option value="Bearer Token">Bearer Token</option>
|
||||
<option value="AWS Key">AWS Access Key</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-bold text-on-surface-variant">LOGIN USERNAME</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="e.g. root or ubuntu"
|
||||
value={newUser}
|
||||
onChange={(e) => setNewUser(e.target.value)}
|
||||
className="w-full bg-surface-container-low border border-outline-variant/40 rounded-lg py-2.5 px-3 focus:outline-none focus:border-primary font-medium"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-bold text-on-surface-variant">PASSPHRASE / TOKEN KEY</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
placeholder="Enter secure password secret..."
|
||||
value={newPass}
|
||||
onChange={(e) => setNewPass(e.target.value)}
|
||||
className="w-full bg-surface-container-low border border-outline-variant/40 rounded-lg py-2.5 px-4 focus:ring-2 focus:ring-primary/20 focus:border-primary focus:outline-none transition-all font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex justify-end gap-3 border-t border-outline-variant/20">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddModal(false)}
|
||||
className="px-4 py-2 text-xs font-bold text-outline hover:text-on-surface"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-primary text-white py-2 px-5 rounded-lg font-bold text-xs shadow-md hover:bg-primary-container hover:text-on-primary-container transition-all"
|
||||
>
|
||||
Save Credential
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Device } from '../types';
|
||||
import { User, Bell, Shield, Smartphone, Laptop, CheckCircle, Zap, Save, RefreshCw, Star, LogOut, Check } from 'lucide-react';
|
||||
|
||||
export default function SettingsView() {
|
||||
const [name, setName] = useState('Alex Rivera');
|
||||
const [email, setEmail] = useState('alex.rivera@hostkeeper.io');
|
||||
const [role, setRole] = useState('Senior DevOps Lead');
|
||||
|
||||
// Toggle states
|
||||
const [keyBackup, setKeyBackup] = useState(true);
|
||||
const [bgSync, setBgSync] = useState(true);
|
||||
const [notifyTransfer, setNotifyTransfer] = useState(false);
|
||||
const [showSaveSuccess, setShowSaveSuccess] = useState(false);
|
||||
|
||||
// Active connected sync devices
|
||||
const [devices, setDevices] = useState<Device[]>([
|
||||
{ id: '1', name: 'Alex MacBook Pro 16"', type: 'desktop', status: 'online' },
|
||||
{ id: '2', name: 'Rivera iPad Air 5', type: 'tablet', status: 'online' },
|
||||
{ id: '3', name: 'iPhone 15 Pro Max', type: 'mobile', status: 'offline' }
|
||||
]);
|
||||
|
||||
const handleSaveProfile = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setShowSaveSuccess(true);
|
||||
setTimeout(() => {
|
||||
setShowSaveSuccess(false);
|
||||
}, 2500);
|
||||
};
|
||||
|
||||
const getDeviceIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'desktop': return <Laptop className="w-5 h-5 text-primary" />;
|
||||
default: return <Smartphone className="w-5 h-5 text-outline" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 md:p-8 max-w-7xl mx-auto space-y-8 animate-fade-in text-sm">
|
||||
|
||||
{/* Toast Alert Simulation */}
|
||||
{showSaveSuccess && (
|
||||
<div className="fixed bottom-6 right-6 z-[180] bg-inverse-surface text-inverse-on-surface px-5 py-3.5 rounded-xl shadow-xl flex items-center gap-3 border border-outline/20 animate-slide-in text-xs font-semibold">
|
||||
<CheckCircle className="w-4 h-4 text-secondary animate-bounce" />
|
||||
<span>Profile parameters updated successfully!</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header Panel */}
|
||||
<div className="border-b border-outline-variant/30 pb-4">
|
||||
<h2 className="text-xl font-bold text-on-surface tracking-tight">Workspace Preferences</h2>
|
||||
<p className="text-xs text-on-surface-variant mt-0.5">Configure authentication sync protocols and cloud billing</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||
|
||||
{/* Left Side: Profile Form and Toggle configs */}
|
||||
<div className="lg:col-span-8 space-y-8">
|
||||
|
||||
{/* Section: Profile Form */}
|
||||
<div className="bg-white p-6 rounded-2xl border border-outline-variant shadow-sm space-y-6">
|
||||
<h3 className="font-bold text-on-surface text-base flex items-center gap-2">
|
||||
<User className="w-5 h-5 text-primary" />
|
||||
Developer Profile
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleSaveProfile} className="grid grid-cols-1 sm:grid-cols-2 gap-5">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-bold text-on-surface-variant">FULL NAME</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full bg-surface-container-low border border-outline-variant/40 rounded-lg py-2.5 px-4 focus:ring-2 focus:ring-primary/20 focus:border-primary focus:outline-none transition-all font-semibold text-on-surface"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-bold text-on-surface-variant">EMAIL ADDRESS</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full bg-surface-container-low border border-outline-variant/40 rounded-lg py-2.5 px-4 focus:ring-2 focus:ring-primary/20 focus:border-primary focus:outline-none transition-all font-medium text-on-surface"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5 sm:col-span-2">
|
||||
<label className="text-xs font-bold text-on-surface-variant">WORKPLACE DESIGNATION / ROLE</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value)}
|
||||
className="w-full bg-surface-container-low border border-outline-variant/40 rounded-lg py-2.5 px-4 focus:ring-2 focus:ring-primary/20 focus:border-primary focus:outline-none transition-all font-medium text-on-surface"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2 pt-2 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="flex items-center gap-2 bg-primary text-white hover:bg-primary-container hover:text-on-primary-container font-bold text-xs py-2.5 px-6 rounded-xl shadow-md transition-all cursor-pointer"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Section: Feature Toggles */}
|
||||
<div className="bg-white p-6 rounded-2xl border border-outline-variant shadow-sm space-y-6">
|
||||
<h3 className="font-bold text-on-surface text-base flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-primary" />
|
||||
Security & Transmission Protocols
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4 divide-y divide-outline-variant/20">
|
||||
|
||||
{/* Toggle Row 1 */}
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<div className="space-y-0.5 pr-4">
|
||||
<h4 className="font-bold text-on-surface text-xs sm:text-sm">Cryptographic Keychain Backup</h4>
|
||||
<p className="text-xs text-on-surface-variant">Synchronize securely encrypted keys automatically inside iCloud/Google drive sandboxes.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setKeyBackup(!keyBackup)}
|
||||
className={`w-11 h-6 rounded-full transition-colors relative flex items-center shrink-0 cursor-pointer ${keyBackup ? 'bg-primary' : 'bg-outline-variant'}`}
|
||||
>
|
||||
<span className={`w-4 h-4 bg-white rounded-full shadow transition-transform absolute ${keyBackup ? 'translate-x-6' : 'translate-x-1'}`}></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Toggle Row 2 */}
|
||||
<div className="flex items-center justify-between pt-3">
|
||||
<div className="space-y-0.5 pr-4">
|
||||
<h4 className="font-bold text-on-surface text-xs sm:text-sm">Real-time SFTP Connection Polling</h4>
|
||||
<p className="text-xs text-on-surface-variant">Enable background file watchers to automatically refresh the directory tree upon local file modifications.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setBgSync(!bgSync)}
|
||||
className={`w-11 h-6 rounded-full transition-colors relative flex items-center shrink-0 cursor-pointer ${bgSync ? 'bg-primary' : 'bg-outline-variant'}`}
|
||||
>
|
||||
<span className={`w-4 h-4 bg-white rounded-full shadow transition-transform absolute ${bgSync ? 'translate-x-6' : 'translate-x-1'}`}></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Toggle Row 3 */}
|
||||
<div className="flex items-center justify-between pt-3">
|
||||
<div className="space-y-0.5 pr-4">
|
||||
<h4 className="font-bold text-on-surface text-xs sm:text-sm">Desktop Push Notifications</h4>
|
||||
<p className="text-xs text-on-surface-variant">Trigger native system indicators upon complete transmissions of download/upload streams.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setNotifyTransfer(!notifyTransfer)}
|
||||
className={`w-11 h-6 rounded-full transition-colors relative flex items-center shrink-0 cursor-pointer ${notifyTransfer ? 'bg-primary' : 'bg-outline-variant'}`}
|
||||
>
|
||||
<span className={`w-4 h-4 bg-white rounded-full shadow transition-transform absolute ${notifyTransfer ? 'translate-x-6' : 'translate-x-1'}`}></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Right Side: Billing Status and Devices List */}
|
||||
<div className="lg:col-span-4 space-y-8">
|
||||
|
||||
{/* Pro Billing Card */}
|
||||
<div className="bg-primary text-on-primary p-6 rounded-2xl shadow-lg relative overflow-hidden flex flex-col justify-between h-48">
|
||||
<div className="relative z-10">
|
||||
<div className="flex justify-between items-start">
|
||||
<span className="px-2.5 py-1 bg-white/20 rounded-full font-bold text-[9px] uppercase tracking-wider text-white">HostKeeper Pro</span>
|
||||
<Star className="w-5 h-5 text-white fill-white" />
|
||||
</div>
|
||||
<h3 className="font-black text-2xl tracking-tight mt-3">Active Workspace</h3>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex justify-between items-end">
|
||||
<div>
|
||||
<span className="text-xs opacity-80 uppercase font-semibold">RENEWAL CYCLE</span>
|
||||
<p className="text-xs font-bold text-white">July 2026</p>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold bg-white text-primary rounded-lg px-2.5 py-1 flex items-center gap-1">
|
||||
<Check className="w-3.5 h-3.5" /> PAID ACCOUNT
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="absolute right-0 bottom-0 opacity-10 pointer-events-none">
|
||||
<Zap className="w-48 h-48 rotate-45" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connected Sync Devices list */}
|
||||
<div className="bg-white p-5 rounded-2xl border border-outline-variant shadow-sm space-y-4">
|
||||
<h3 className="font-bold text-on-surface text-sm flex items-center gap-2">
|
||||
<Smartphone className="w-4.5 h-4.5 text-primary" />
|
||||
Synchronized Devices
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3.5">
|
||||
{devices.map((device) => (
|
||||
<div key={device.id} className="flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="p-1.5 bg-surface-container-low border border-outline-variant/20 rounded-lg text-primary shrink-0">
|
||||
{getDeviceIcon(device.type)}
|
||||
</div>
|
||||
<span className="font-bold text-on-surface truncate pr-2">{device.name}</span>
|
||||
</div>
|
||||
|
||||
<span className={`px-2 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-wider flex items-center gap-1 shrink-0 ${device.status === 'online' ? 'bg-secondary-container/20 text-secondary' : 'bg-surface-container text-outline'}`}>
|
||||
<span className={`w-1 h-1 rounded-full ${device.status === 'online' ? 'bg-secondary animate-pulse' : 'bg-outline'}`}></span>
|
||||
{device.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<div className="p-4 border border-error-container/40 rounded-2xl bg-error-container/5 space-y-3 text-xs">
|
||||
<h4 className="font-bold text-error flex items-center gap-2">
|
||||
<LogOut className="w-4 h-4" /> Reset Local State
|
||||
</h4>
|
||||
<p className="text-on-surface-variant">Resetting will clear all your locally loaded keystores, snippets, and connection cache buffers from the browser storage.</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
if(confirm("Are you sure you want to flush all local storage configurations?")) {
|
||||
localStorage.clear();
|
||||
alert("Workspace storage cleared successfully.");
|
||||
window.location.reload();
|
||||
}
|
||||
}}
|
||||
className="w-full py-2 border border-error/30 rounded-xl font-bold text-error hover:bg-error/10 transition-colors"
|
||||
>
|
||||
Flush All App State
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
import React, { useState } from 'react';
|
||||
import { FileItem } from '../types';
|
||||
import {
|
||||
Folder,
|
||||
File,
|
||||
FileCode,
|
||||
FileImage,
|
||||
Lock,
|
||||
ArrowLeftRight,
|
||||
Upload,
|
||||
Download,
|
||||
RefreshCw,
|
||||
ChevronRight,
|
||||
Search,
|
||||
FolderPlus,
|
||||
Trash2,
|
||||
CheckCircle,
|
||||
HardDrive,
|
||||
Globe
|
||||
} from 'lucide-react';
|
||||
|
||||
export default function SftpView() {
|
||||
const [localPath, setLocalPath] = useState('/Users/alex/workspace/hostkeeper');
|
||||
const [remotePath, setRemotePath] = useState('/var/www/prod-api-cluster/src');
|
||||
const [localSearch, setLocalSearch] = useState('');
|
||||
const [remoteSearch, setRemoteSearch] = useState('');
|
||||
|
||||
// Active Toast simulation state
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||
|
||||
// Initial local mock file system
|
||||
const [localFiles, setLocalFiles] = useState<FileItem[]>([
|
||||
{ name: 'public', size: '—', modified: '2 hours ago', type: 'folder' },
|
||||
{ name: 'src', size: '—', modified: '10 mins ago', type: 'folder' },
|
||||
{ name: 'package.json', size: '1.4 KB', modified: 'Just now', type: 'code' },
|
||||
{ name: 'vite.config.ts', size: '860 B', modified: '2 days ago', type: 'code' },
|
||||
{ name: 'tailwind.config.js', size: '1.1 KB', modified: '3 days ago', type: 'code' },
|
||||
{ name: 'App.tsx', size: '12.4 KB', modified: 'Just now', type: 'code' },
|
||||
{ name: 'logo_hero.png', size: '240 KB', modified: '1 week ago', type: 'image' },
|
||||
{ name: '.env', size: '280 B', modified: 'Yesterday', type: 'lock' }
|
||||
]);
|
||||
|
||||
// Initial remote mock file system
|
||||
const [remoteFiles, setRemoteFiles] = useState<FileItem[]>([
|
||||
{ name: 'controllers', size: '—', modified: '1 day ago', type: 'folder' },
|
||||
{ name: 'models', size: '—', modified: '1 day ago', type: 'folder' },
|
||||
{ name: 'routes', size: '—', modified: '4 hours ago', type: 'folder' },
|
||||
{ name: 'server.js', size: '8.4 KB', modified: '2 hours ago', type: 'code' },
|
||||
{ name: 'config.production.json', size: '2.1 KB', modified: '1 week ago', type: 'lock' },
|
||||
{ name: 'index.html', size: '3.2 KB', modified: '3 hours ago', type: 'code' },
|
||||
{ name: 'avatar_default.jpg', size: '18 KB', modified: '2 weeks ago', type: 'image' },
|
||||
{ name: 'node_modules.tar.gz', size: '42 MB', modified: '3 days ago', type: 'file' }
|
||||
]);
|
||||
|
||||
const triggerToast = (msg: string) => {
|
||||
setToastMessage(msg);
|
||||
setTimeout(() => {
|
||||
setToastMessage(null);
|
||||
}, 3500);
|
||||
};
|
||||
|
||||
const handleLocalClick = (item: FileItem) => {
|
||||
if (item.type === 'folder') {
|
||||
setLocalPath(`${localPath}/${item.name}`);
|
||||
triggerToast(`Navigated into local folder: ${item.name}`);
|
||||
} else {
|
||||
triggerToast(`Uploading "${item.name}" to remote server...`);
|
||||
// Simulate transfer additions
|
||||
setTimeout(() => {
|
||||
setRemoteFiles(prev => [
|
||||
{ name: item.name, size: item.size, modified: 'Just now', type: item.type },
|
||||
...prev
|
||||
]);
|
||||
triggerToast(`Successfully uploaded "${item.name}" to ${remotePath}`);
|
||||
}, 1500);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoteClick = (item: FileItem) => {
|
||||
if (item.type === 'folder') {
|
||||
setRemotePath(`${remotePath}/${item.name}`);
|
||||
triggerToast(`Navigated remote server path to: ${item.name}`);
|
||||
} else {
|
||||
triggerToast(`Downloading "${item.name}" from remote server...`);
|
||||
setTimeout(() => {
|
||||
setLocalFiles(prev => [
|
||||
{ name: item.name, size: item.size, modified: 'Just now', type: item.type },
|
||||
...prev
|
||||
]);
|
||||
triggerToast(`Successfully downloaded "${item.name}" to local environment.`);
|
||||
}, 1500);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateFolder = (target: 'local' | 'remote') => {
|
||||
const folderName = prompt(`Enter new folder name for ${target}:`);
|
||||
if (!folderName) return;
|
||||
|
||||
const newFolder: FileItem = {
|
||||
name: folderName,
|
||||
size: '—',
|
||||
modified: 'Just now',
|
||||
type: 'folder'
|
||||
};
|
||||
|
||||
if (target === 'local') {
|
||||
setLocalFiles([newFolder, ...localFiles]);
|
||||
triggerToast(`Created folder "${folderName}" locally.`);
|
||||
} else {
|
||||
setRemoteFiles([newFolder, ...remoteFiles]);
|
||||
triggerToast(`Created folder "${folderName}" on remote host.`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFile = (name: string, target: 'local' | 'remote') => {
|
||||
if (target === 'local') {
|
||||
setLocalFiles(prev => prev.filter(f => f.name !== name));
|
||||
triggerToast(`Deleted local file "${name}"`);
|
||||
} else {
|
||||
setRemoteFiles(prev => prev.filter(f => f.name !== name));
|
||||
triggerToast(`Deleted remote file "${name}"`);
|
||||
}
|
||||
};
|
||||
|
||||
const getFileIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'folder':
|
||||
return <Folder className="w-4 h-4 text-primary" />;
|
||||
case 'code':
|
||||
return <FileCode className="w-4 h-4 text-tertiary" />;
|
||||
case 'image':
|
||||
return <FileImage className="w-4 h-4 text-secondary" />;
|
||||
case 'lock':
|
||||
return <Lock className="w-4 h-4 text-error" />;
|
||||
default:
|
||||
return <File className="w-4 h-4 text-outline" />;
|
||||
}
|
||||
};
|
||||
|
||||
const filteredLocal = localFiles.filter(f => f.name.toLowerCase().includes(localSearch.toLowerCase()));
|
||||
const filteredRemote = remoteFiles.filter(f => f.name.toLowerCase().includes(remoteSearch.toLowerCase()));
|
||||
|
||||
return (
|
||||
<div className="p-6 md:p-8 max-w-7xl mx-auto space-y-6 animate-fade-in relative">
|
||||
{/* Toast Alert simulation */}
|
||||
{toastMessage && (
|
||||
<div className="fixed bottom-6 right-6 z-[180] bg-inverse-surface text-inverse-on-surface px-5 py-3.5 rounded-xl shadow-xl flex items-center gap-3 border border-outline/20 animate-slide-in text-xs font-semibold">
|
||||
<CheckCircle className="w-4 h-4 text-secondary animate-bounce" />
|
||||
<span>{toastMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header Panel */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-outline-variant/30 pb-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-on-surface tracking-tight">SFTP Secure File Sync</h2>
|
||||
<p className="text-xs text-on-surface-variant mt-0.5">Dual streams for real-time asset transmission and file editing</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setLocalFiles([
|
||||
{ name: 'public', size: '—', modified: '2 hours ago', type: 'folder' },
|
||||
{ name: 'src', size: '—', modified: '10 mins ago', type: 'folder' },
|
||||
{ name: 'package.json', size: '1.4 KB', modified: 'Just now', type: 'code' },
|
||||
{ name: 'vite.config.ts', size: '860 B', modified: '2 days ago', type: 'code' },
|
||||
{ name: 'tailwind.config.js', size: '1.1 KB', modified: '3 days ago', type: 'code' }
|
||||
]);
|
||||
setRemoteFiles([
|
||||
{ name: 'controllers', size: '—', modified: '1 day ago', type: 'folder' },
|
||||
{ name: 'models', size: '—', modified: '1 day ago', type: 'folder' },
|
||||
{ name: 'server.js', size: '8.4 KB', modified: '2 hours ago', type: 'code' }
|
||||
]);
|
||||
triggerToast('Refreshed workspace file tree arrays.');
|
||||
}}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg border border-outline-variant hover:border-primary text-xs font-semibold text-on-surface bg-white transition-all hover:bg-surface-container"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
Reset State
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dual Panel Body */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
|
||||
{/* Local Stream File Panel */}
|
||||
<div className="bg-white rounded-2xl border border-outline-variant overflow-hidden shadow-sm flex flex-col h-[520px]">
|
||||
{/* Stream Header */}
|
||||
<div className="p-4 border-b border-outline-variant/50 bg-surface-container-low flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<HardDrive className="w-4 h-4 text-outline shrink-0" />
|
||||
<span className="font-bold text-xs text-on-surface truncate uppercase tracking-wider">Local Workstation</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleCreateFolder('local')}
|
||||
className="text-primary hover:text-primary-container p-1 rounded hover:bg-primary/5 transition-all"
|
||||
title="New Local Folder"
|
||||
>
|
||||
<FolderPlus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb path navigation bar */}
|
||||
<div className="px-4 py-2 border-b border-outline-variant/20 bg-surface-container-lowest flex items-center gap-1.5 text-xs text-outline font-mono overflow-x-auto no-scrollbar">
|
||||
<span className="text-primary-fixed-dim hover:underline cursor-pointer" onClick={() => setLocalPath('/Users/alex')}>alex</span>
|
||||
<ChevronRight className="w-3 h-3 text-outline/40 shrink-0" />
|
||||
<span className="text-primary-fixed-dim hover:underline cursor-pointer" onClick={() => setLocalPath('/Users/alex/workspace')}>workspace</span>
|
||||
<ChevronRight className="w-3 h-3 text-outline/40 shrink-0" />
|
||||
<span className="text-on-surface truncate">{localPath.split('/').pop()}</span>
|
||||
</div>
|
||||
|
||||
{/* File Stream Search Filter */}
|
||||
<div className="p-3 border-b border-outline-variant/10 relative">
|
||||
<Search className="absolute left-6 top-1/2 -translate-y-1/2 text-outline w-3.5 h-3.5" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter local files..."
|
||||
value={localSearch}
|
||||
onChange={(e) => setLocalSearch(e.target.value)}
|
||||
className="w-full bg-surface-container-low pl-10 pr-4 py-1.5 border border-outline-variant/30 rounded-lg text-xs font-medium focus:outline-none focus:border-primary focus:bg-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Local files list table */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<table className="w-full text-left border-collapse text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-outline-variant/20 text-outline uppercase tracking-wider font-bold">
|
||||
<th className="py-2.5 px-4 font-semibold text-[10px]">Name</th>
|
||||
<th className="py-2.5 px-4 font-semibold text-[10px] text-right">Size</th>
|
||||
<th className="py-2.5 px-4 font-semibold text-[10px] text-right">Modified</th>
|
||||
<th className="py-2.5 px-4 font-semibold text-[10px]"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredLocal.map((file) => (
|
||||
<tr
|
||||
key={file.name}
|
||||
className="border-b border-outline-variant/10 hover:bg-primary/5 transition-colors group cursor-pointer"
|
||||
>
|
||||
<td className="py-2.5 px-4">
|
||||
<div
|
||||
onClick={() => handleLocalClick(file)}
|
||||
className="font-semibold text-on-surface flex items-center gap-2.5 max-w-[180px] cursor-pointer"
|
||||
>
|
||||
<span className="shrink-0">{getFileIcon(file.type)}</span>
|
||||
<span className="truncate group-hover:text-primary transition-colors">{file.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2.5 px-4 text-right text-on-surface-variant font-mono">{file.size}</td>
|
||||
<td className="py-2.5 px-4 text-right text-outline">{file.modified}</td>
|
||||
<td className="py-2.5 px-4 text-right">
|
||||
<div className="flex items-center justify-end gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => handleLocalClick(file)}
|
||||
className="p-1 hover:bg-primary/10 rounded text-primary transition-colors"
|
||||
title="Upload to remote"
|
||||
>
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteFile(file.name, 'local')}
|
||||
className="p-1 hover:bg-error-container hover:text-error rounded text-outline transition-colors"
|
||||
title="Delete file"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-surface-container-low border-t border-outline-variant text-[10px] text-outline text-center font-bold">
|
||||
TIP: Click file or upload button to transmit to server
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remote Stream File Panel */}
|
||||
<div className="bg-white rounded-2xl border border-outline-variant overflow-hidden shadow-sm flex flex-col h-[520px]">
|
||||
{/* Stream Header */}
|
||||
<div className="p-4 border-b border-outline-variant/50 bg-surface-container-low flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Globe className="w-4 h-4 text-primary shrink-0" />
|
||||
<span className="font-bold text-xs text-on-surface truncate uppercase tracking-wider">Remote Cluster Node</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleCreateFolder('remote')}
|
||||
className="text-primary hover:text-primary-container p-1 rounded hover:bg-primary/5 transition-all"
|
||||
title="New Remote Folder"
|
||||
>
|
||||
<FolderPlus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb path navigation bar */}
|
||||
<div className="px-4 py-2 border-b border-outline-variant/20 bg-surface-container-lowest flex items-center gap-1.5 text-xs text-outline font-mono overflow-x-auto no-scrollbar">
|
||||
<span className="text-primary-fixed-dim hover:underline cursor-pointer" onClick={() => setRemotePath('/var')}>var</span>
|
||||
<ChevronRight className="w-3 h-3 text-outline/40 shrink-0" />
|
||||
<span className="text-primary-fixed-dim hover:underline cursor-pointer" onClick={() => setRemotePath('/var/www')}>www</span>
|
||||
<ChevronRight className="w-3 h-3 text-outline/40 shrink-0" />
|
||||
<span className="text-on-surface truncate">{remotePath.split('/').pop()}</span>
|
||||
</div>
|
||||
|
||||
{/* File Stream Search Filter */}
|
||||
<div className="p-3 border-b border-outline-variant/10 relative">
|
||||
<Search className="absolute left-6 top-1/2 -translate-y-1/2 text-outline w-3.5 h-3.5" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter remote files..."
|
||||
value={remoteSearch}
|
||||
onChange={(e) => setRemoteSearch(e.target.value)}
|
||||
className="w-full bg-surface-container-low pl-10 pr-4 py-1.5 border border-outline-variant/30 rounded-lg text-xs font-medium focus:outline-none focus:border-primary focus:bg-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Remote files list table */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<table className="w-full text-left border-collapse text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-outline-variant/20 text-outline uppercase tracking-wider font-bold">
|
||||
<th className="py-2.5 px-4 font-semibold text-[10px]">Name</th>
|
||||
<th className="py-2.5 px-4 font-semibold text-[10px] text-right">Size</th>
|
||||
<th className="py-2.5 px-4 font-semibold text-[10px] text-right">Modified</th>
|
||||
<th className="py-2.5 px-4 font-semibold text-[10px]"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredRemote.map((file) => (
|
||||
<tr
|
||||
key={file.name}
|
||||
className="border-b border-outline-variant/10 hover:bg-primary/5 transition-colors group cursor-pointer"
|
||||
>
|
||||
<td className="py-2.5 px-4">
|
||||
<div
|
||||
onClick={() => handleRemoteClick(file)}
|
||||
className="font-semibold text-on-surface flex items-center gap-2.5 max-w-[180px] cursor-pointer"
|
||||
>
|
||||
<span className="shrink-0">{getFileIcon(file.type)}</span>
|
||||
<span className="truncate group-hover:text-primary transition-colors">{file.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2.5 px-4 text-right text-on-surface-variant font-mono">{file.size}</td>
|
||||
<td className="py-2.5 px-4 text-right text-outline">{file.modified}</td>
|
||||
<td className="py-2.5 px-4 text-right">
|
||||
<div className="flex items-center justify-end gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => handleRemoteClick(file)}
|
||||
className="p-1 hover:bg-primary/10 rounded text-primary transition-colors"
|
||||
title="Download to local"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteFile(file.name, 'remote')}
|
||||
className="p-1 hover:bg-error-container hover:text-error rounded text-outline transition-colors"
|
||||
title="Delete file"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-surface-container-low border-t border-outline-variant text-[10px] text-outline text-center font-bold">
|
||||
TIP: Click file or download button to fetch files to local system
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Drag and Drop Zone Simulator */}
|
||||
<div className="p-8 border-2 border-dashed border-outline-variant rounded-2xl text-center bg-white/40 flex flex-col items-center justify-center space-y-2 relative group hover:border-primary transition-colors">
|
||||
<ArrowLeftRight className="w-10 h-10 text-outline group-hover:text-primary group-hover:scale-110 transition-transform" />
|
||||
<h3 className="font-bold text-sm text-on-surface">Drag files directly here to start batch upload</h3>
|
||||
<p className="text-xs text-on-surface-variant max-w-sm">
|
||||
Files are automatically split into parallel streams of 5MB chunks and secured with AES-256 keychain keys.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Snippet } from '../types';
|
||||
import { Search, Copy, Check, Terminal, Folder, Star, Clock, FolderGit, Cpu, Trash2, Plus, Zap, FileText } from 'lucide-react';
|
||||
|
||||
export default function SnippetsView() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeCollection, setActiveCollection] = useState<'all' | 'favorites' | 'recent'>('all');
|
||||
const [activeTag, setActiveTag] = useState<string>('all');
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
|
||||
// Snippets Initial Database State
|
||||
const [snippets, setSnippets] = useState<Snippet[]>([
|
||||
{
|
||||
id: '1',
|
||||
title: 'Docker Resource Prune Suite',
|
||||
description: 'Force clean-up unused container images, volumes, logs, networks, and untagged builds safely.',
|
||||
code: 'docker system prune -a --volumes --force',
|
||||
tags: ['Docker', 'DevOps'],
|
||||
collection: 'favorites',
|
||||
updatedAt: '2 hours ago',
|
||||
icon: 'Cpu'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Nginx TLS Reverse Proxy Template',
|
||||
description: 'Standard secure production proxy server configuration block forwarding upstream headers.',
|
||||
code: `server {
|
||||
listen 443 ssl http2;
|
||||
server_name api.domain.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}`,
|
||||
tags: ['Nginx', 'SSL'],
|
||||
collection: 'favorites',
|
||||
updatedAt: 'Yesterday',
|
||||
icon: 'Folder'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Git Force Fetch and Hard Reset',
|
||||
description: 'Discards local changes completely and resets HEAD to track remote production origin main.',
|
||||
code: 'git fetch origin && git reset --hard origin/main',
|
||||
tags: ['Git', 'VCS'],
|
||||
collection: 'recent',
|
||||
updatedAt: '3 days ago',
|
||||
icon: 'FolderGit'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Inspect SSL Certification Expiry',
|
||||
description: 'Checks expiry metrics and issuer of ssl keys on active remote addresses.',
|
||||
code: 'openssl s_client -connect google.com:443 | openssl x509 -noout -dates',
|
||||
tags: ['SSL', 'Security'],
|
||||
collection: 'recent',
|
||||
updatedAt: 'Last week',
|
||||
icon: 'Cpu'
|
||||
}
|
||||
]);
|
||||
|
||||
const [newTitle, setNewTitle] = useState('');
|
||||
const [newDesc, setNewDesc] = useState('');
|
||||
const [newCode, setNewCode] = useState('');
|
||||
const [newTag, setNewTag] = useState('DevOps');
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
|
||||
const handleCopyCode = (id: string, code: string) => {
|
||||
navigator.clipboard.writeText(code);
|
||||
setCopiedId(id);
|
||||
setTimeout(() => {
|
||||
setCopiedId(null);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const handleDeleteSnippet = (id: string) => {
|
||||
setSnippets(prev => prev.filter(s => s.id !== id));
|
||||
};
|
||||
|
||||
const handleCreateSnippet = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newTitle || !newCode) return;
|
||||
|
||||
const added: Snippet = {
|
||||
id: Date.now().toString(),
|
||||
title: newTitle,
|
||||
description: newDesc,
|
||||
code: newCode,
|
||||
tags: [newTag],
|
||||
collection: 'recent',
|
||||
updatedAt: 'Just now',
|
||||
icon: 'FileText'
|
||||
};
|
||||
|
||||
setSnippets([added, ...snippets]);
|
||||
setShowAddForm(false);
|
||||
setNewTitle('');
|
||||
setNewDesc('');
|
||||
setNewCode('');
|
||||
};
|
||||
|
||||
const allTags = ['all', ...Array.from(new Set(snippets.flatMap(s => s.tags)))];
|
||||
|
||||
const filteredSnippets = snippets.filter(s => {
|
||||
const matchesSearch = s.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
s.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
s.code.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
const matchesCollection = activeCollection === 'all' || s.collection === activeCollection;
|
||||
const matchesTag = activeTag === 'all' || s.tags.includes(activeTag);
|
||||
|
||||
return matchesSearch && matchesCollection && matchesTag;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-6 md:p-8 max-w-7xl mx-auto space-y-8 animate-fade-in">
|
||||
|
||||
{/* Search Header Panel */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-outline-variant/30 pb-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-on-surface tracking-tight">Snippets & Scripts Library</h2>
|
||||
<p className="text-xs text-on-surface-variant mt-0.5">Quickly reuse and catalog command line shortcuts across remote clusters</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-outline w-4 h-4" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search code snippets..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 pr-4 py-1.5 rounded-full bg-surface-container-low border border-outline-variant/30 text-sm focus:border-primary focus:bg-white focus:outline-none focus:ring-1 focus:ring-primary w-48 sm:w-60 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="flex items-center gap-1.5 bg-primary text-white hover:bg-primary-container hover:text-on-primary-container font-bold text-xs py-2 px-4 rounded-xl shadow-md transition-all cursor-pointer"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
New Snippet
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid Architecture */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||
{/* Left category rails filter */}
|
||||
<div className="lg:col-span-3 space-y-5">
|
||||
<div className="bg-white rounded-2xl border border-outline-variant p-4 space-y-4">
|
||||
<h4 className="text-xs font-bold text-outline-variant uppercase tracking-wider px-2">Collections</h4>
|
||||
<div className="space-y-1 text-sm font-semibold">
|
||||
<button
|
||||
onClick={() => setActiveCollection('all')}
|
||||
className={`w-full flex items-center justify-between p-2 rounded-lg transition-colors ${activeCollection === 'all' ? 'bg-primary/10 text-primary' : 'text-on-surface-variant hover:bg-surface-container-low'}`}
|
||||
>
|
||||
<span className="flex items-center gap-2"><Folder className="w-4 h-4" /> All Code Blocks</span>
|
||||
<span className="text-xs text-outline">{snippets.length}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveCollection('favorites')}
|
||||
className={`w-full flex items-center justify-between p-2 rounded-lg transition-colors ${activeCollection === 'favorites' ? 'bg-primary/10 text-primary' : 'text-on-surface-variant hover:bg-surface-container-low'}`}
|
||||
>
|
||||
<span className="flex items-center gap-2"><Star className="w-4 h-4 text-amber-500 fill-amber-500" /> Starred Favorites</span>
|
||||
<span className="text-xs text-outline">{snippets.filter(s => s.collection === 'favorites').length}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveCollection('recent')}
|
||||
className={`w-full flex items-center justify-between p-2 rounded-lg transition-colors ${activeCollection === 'recent' ? 'bg-primary/10 text-primary' : 'text-on-surface-variant hover:bg-surface-container-low'}`}
|
||||
>
|
||||
<span className="flex items-center gap-2"><Clock className="w-4 h-4" /> Recent</span>
|
||||
<span className="text-xs text-outline">{snippets.filter(s => s.collection === 'recent').length}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl border border-outline-variant p-4 space-y-3">
|
||||
<h4 className="text-xs font-bold text-outline-variant uppercase tracking-wider px-2">Tag Categories</h4>
|
||||
<div className="flex flex-wrap gap-1.5 p-1">
|
||||
{allTags.map(tag => (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => setActiveTag(tag)}
|
||||
className={`px-3 py-1 rounded-full text-xs font-bold transition-all ${activeTag === tag ? 'bg-primary text-white' : 'bg-surface-container-low text-on-surface-variant hover:bg-surface-container'}`}
|
||||
>
|
||||
{tag === 'all' ? 'All Tags' : tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right main snippets cards list */}
|
||||
<div className="lg:col-span-9 space-y-6">
|
||||
{filteredSnippets.length === 0 ? (
|
||||
<div className="p-12 border border-dashed border-outline-variant rounded-2xl text-center text-outline">
|
||||
No matching snippet found in {activeCollection} category.
|
||||
</div>
|
||||
) : (
|
||||
filteredSnippets.map((snippet) => (
|
||||
<div
|
||||
key={snippet.id}
|
||||
className="bg-white border border-outline-variant rounded-2xl overflow-hidden shadow-sm flex flex-col group"
|
||||
>
|
||||
<div className="p-5 flex justify-between items-start gap-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="font-bold text-on-surface text-base group-hover:text-primary transition-colors">{snippet.title}</h3>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{snippet.tags.map(t => (
|
||||
<span key={t} className="px-2 py-0.5 bg-surface-container text-[10px] font-bold rounded text-on-surface-variant">
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-on-surface-variant leading-relaxed">{snippet.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => handleCopyCode(snippet.id, snippet.code)}
|
||||
className="p-1.5 border border-outline-variant rounded-lg bg-surface-container-low hover:border-primary text-outline hover:text-primary transition-colors cursor-pointer"
|
||||
title="Copy code to clipboard"
|
||||
>
|
||||
{copiedId === snippet.id ? <Check className="w-4 h-4 text-secondary" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteSnippet(snippet.id)}
|
||||
className="p-1.5 border border-outline-variant rounded-lg bg-surface-container-low hover:border-error text-outline hover:text-error transition-colors"
|
||||
title="Delete code snippet"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Code syntax terminal container */}
|
||||
<div className="bg-surface-container-low border-t border-outline-variant/40 p-4 font-mono text-xs text-on-surface relative group/code overflow-x-auto">
|
||||
<pre className="text-on-surface select-all leading-relaxed whitespace-pre font-medium">{snippet.code}</pre>
|
||||
<button
|
||||
onClick={() => handleCopyCode(snippet.id, snippet.code)}
|
||||
className="absolute right-4 top-4 bg-white/90 backdrop-blur border border-outline-variant shadow px-2.5 py-1 rounded text-[10px] font-mono font-bold hover:border-primary hover:text-primary transition-colors opacity-0 group-hover/code:opacity-100"
|
||||
>
|
||||
{copiedId === snippet.id ? 'COPIED ✓' : 'COPY'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-2 border-t border-outline-variant/20 bg-surface-container-lowest flex justify-between items-center text-[10px] text-outline font-semibold">
|
||||
<span>Last adjusted {snippet.updatedAt}</span>
|
||||
<span className="flex items-center gap-1 text-primary"><Zap className="w-3 h-3 fill-primary/10" /> Ready to inject to shell</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Snippet Modal Dialog */}
|
||||
{showAddForm && (
|
||||
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm z-[150] flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-2xl max-w-lg w-full border border-outline-variant shadow-2xl p-6 space-y-4 animate-scale-up">
|
||||
<div className="flex justify-between items-center border-b border-outline-variant/30 pb-3">
|
||||
<h3 className="font-bold text-lg text-on-surface">Store Custom Snippet</h3>
|
||||
<button
|
||||
onClick={() => setShowAddForm(false)}
|
||||
className="text-outline hover:text-primary cursor-pointer p-1 rounded"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCreateSnippet} className="space-y-4 text-sm">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-bold text-on-surface-variant">SNIPPET TITLE</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
placeholder="e.g. Purge Docker Cache"
|
||||
value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
className="w-full bg-surface-container-low border border-outline-variant/40 rounded-lg py-2.5 px-4 focus:ring-2 focus:ring-primary/20 focus:border-primary focus:outline-none transition-all font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-bold text-on-surface-variant">SHORT DESCRIPTION / USAGE</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Safe cleanup to recover workspace disk capacity."
|
||||
value={newDesc}
|
||||
onChange={(e) => setNewDesc(e.target.value)}
|
||||
className="w-full bg-surface-container-low border border-outline-variant/40 rounded-lg py-2.5 px-4 focus:ring-2 focus:ring-primary/20 focus:border-primary focus:outline-none transition-all font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-bold text-on-surface-variant">CODE SYNTAX BLOCK</label>
|
||||
<textarea
|
||||
required
|
||||
rows={4}
|
||||
placeholder="e.g. systemctl restart nginx"
|
||||
value={newCode}
|
||||
onChange={(e) => setNewCode(e.target.value)}
|
||||
className="w-full bg-surface-container-low border border-outline-variant/40 rounded-lg py-2.5 px-4 focus:ring-2 focus:ring-primary/20 focus:border-primary focus:outline-none transition-all font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-bold text-on-surface-variant">PRIMARY TAG CATEGORY</label>
|
||||
<select
|
||||
value={newTag}
|
||||
onChange={(e) => setNewTag(e.target.value)}
|
||||
className="bg-surface-container-low border border-outline-variant/40 rounded-lg py-2 px-3 focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="DevOps">DevOps</option>
|
||||
<option value="Docker">Docker</option>
|
||||
<option value="Nginx">Nginx</option>
|
||||
<option value="Git">Git</option>
|
||||
<option value="SSL">SSL</option>
|
||||
<option value="Security">Security</option>
|
||||
<option value="Database">Database</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex justify-end gap-3 border-t border-outline-variant/20">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddForm(false)}
|
||||
className="px-4 py-2 text-xs font-bold text-outline hover:text-on-surface"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-primary text-white py-2 px-5 rounded-lg font-bold text-xs shadow-md hover:bg-primary-container hover:text-on-primary-container transition-all"
|
||||
>
|
||||
Save Code Snippet
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Terminal as TermIcon, TerminalSquare, AlertCircle, Plus, X, ArrowRight, CornerDownLeft, Play, RefreshCw, Layers } from 'lucide-react';
|
||||
|
||||
interface TerminalLine {
|
||||
text: string;
|
||||
type: 'input' | 'output' | 'error' | 'success';
|
||||
}
|
||||
|
||||
interface Tab {
|
||||
id: string;
|
||||
title: string;
|
||||
host: string;
|
||||
}
|
||||
|
||||
export default function TerminalView() {
|
||||
const [tabs, setTabs] = useState<Tab[]>([
|
||||
{ id: '1', title: 'production-api-01', host: '10.0.1.201' },
|
||||
{ id: '2', title: 'db-cluster-main', host: '10.0.2.14' }
|
||||
]);
|
||||
const [activeTabId, setActiveTabId] = useState('1');
|
||||
const [commandInput, setCommandInput] = useState('');
|
||||
|
||||
// Separate history logs per tab
|
||||
const [histories, setHistories] = useState<Record<string, TerminalLine[]>>({
|
||||
'1': [
|
||||
{ text: 'Connecting to production-api-01 (10.0.1.201) via port 22...', type: 'output' },
|
||||
{ text: 'Using identity keychain file: id_ed25519_alex (strength: SECURE)', type: 'output' },
|
||||
{ text: 'Welcome to Ubuntu 22.04 LTS (GNU/Linux 5.15.0-101-generic x86_64)', type: 'success' },
|
||||
{ text: 'System load: 0.12 | Processes: 104 | Memory: 32% used', type: 'output' },
|
||||
{ text: 'Type "help" to view custom interactive HostKeeper mock commands.', type: 'success' },
|
||||
],
|
||||
'2': [
|
||||
{ text: 'Connecting to db-cluster-main (10.0.2.14) via port 5432...', type: 'output' },
|
||||
{ text: 'Using identity password lookup: root_pg_prod (strength: STRONG)', type: 'output' },
|
||||
{ text: 'PostgreSQL 15.3 (Debian 15.3-1.pgdg110+1) on x86_64-pc-linux-gnu', type: 'success' },
|
||||
{ text: 'Type "help" to view custom database commands.', type: 'success' },
|
||||
]
|
||||
});
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [histories, activeTabId]);
|
||||
|
||||
const activeTab = tabs.find(t => t.id === activeTabId) || tabs[0];
|
||||
const activeHistory = histories[activeTabId] || [];
|
||||
|
||||
const handleCommandSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const cmd = commandInput.trim();
|
||||
if (!cmd) return;
|
||||
|
||||
// Add user command input line to logs
|
||||
const newUserLine: TerminalLine = { text: `$ ${cmd}`, type: 'input' };
|
||||
const currentHist = histories[activeTabId] || [];
|
||||
let updatedLines = [...currentHist, newUserLine];
|
||||
|
||||
// Process commands
|
||||
const cleanCmd = cmd.toLowerCase();
|
||||
|
||||
if (cleanCmd === 'clear') {
|
||||
updatedLines = [];
|
||||
} else if (cleanCmd === 'help') {
|
||||
updatedLines.push(
|
||||
{ text: 'HostKeeper Mock SSH Interactive Command Parser:', type: 'success' },
|
||||
{ text: ' help - Display this support manifest list', type: 'output' },
|
||||
{ text: ' ls - List contents of the current working directory', type: 'output' },
|
||||
{ text: ' docker ps - List simulated running Docker containers on cluster', type: 'output' },
|
||||
{ text: ' uname -a - Show operating system and machine kernel data', type: 'output' },
|
||||
{ text: ' ping 8.8.8.8 - Probe network gateway performance', type: 'output' },
|
||||
{ text: ' cat server.js - Output snippet of remote index server configuration', type: 'output' },
|
||||
{ text: ' keychain - Query keychain credentials loaded for target session', type: 'output' },
|
||||
{ text: ' clear - Wipe the terminal display buffer clean', type: 'output' }
|
||||
);
|
||||
} else if (cleanCmd === 'ls') {
|
||||
updatedLines.push(
|
||||
{ text: 'drwxr-xr-x 3 root root 4096 Jul 6 12:00 controllers', type: 'output' },
|
||||
{ text: 'drwxr-xr-x 2 root root 4096 Jul 6 12:00 models', type: 'output' },
|
||||
{ text: 'drwxr-xr-x 2 root root 4096 Jul 6 12:00 routes', type: 'output' },
|
||||
{ text: '-rw-r--r-- 1 root root 280 Jul 6 11:34 .env', type: 'error' },
|
||||
{ text: '-rw-r--r-- 1 root root 1432 Jul 6 14:20 package.json', type: 'output' },
|
||||
{ text: '-rwxr-xr-x 1 root root 8412 Jul 6 15:43 server.js', type: 'success' }
|
||||
);
|
||||
} else if (cleanCmd === 'docker ps') {
|
||||
updatedLines.push(
|
||||
{ text: 'CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS', type: 'success' },
|
||||
{ text: 'f87a2d12e9b0 node:18-alpine "docker-entrypoint.s…" 2 hours ago Up 2 hours 0.0.0.0:3000->3000/tcp', type: 'output' },
|
||||
{ text: 'c23e84bf9211 postgres:15-alpine "docker-entrypoint.s…" 5 hours ago Up 5 hours 0.0.0.0:5432->5432/tcp', type: 'output' },
|
||||
{ text: '78da12b84e0c redis:7-alpine "docker-entrypoint.s…" 10 hours ago Up 10 hours 0.0.0.0:6379->6379/tcp', type: 'output' }
|
||||
);
|
||||
} else if (cleanCmd === 'uname -a') {
|
||||
updatedLines.push(
|
||||
{ text: `Linux ${activeTab.title} 5.15.0-101-generic #111-Ubuntu SMP Wed Jul 6 21:27:00 UTC 2026 x86_64 GNU/Linux`, type: 'output' }
|
||||
);
|
||||
} else if (cleanCmd.startsWith('ping')) {
|
||||
updatedLines.push(
|
||||
{ text: '64 bytes from 8.8.8.8: icmp_seq=1 ttl=116 time=12.4 ms', type: 'output' },
|
||||
{ text: '64 bytes from 8.8.8.8: icmp_seq=2 ttl=116 time=14.1 ms', type: 'output' },
|
||||
{ text: '64 bytes from 8.8.8.8: icmp_seq=3 ttl=116 time=11.8 ms', type: 'output' },
|
||||
{ text: '--- 8.8.8.8 ping statistics ---', type: 'success' },
|
||||
{ text: '3 packets transmitted, 3 received, 0% packet loss, rtt min/avg/max = 11.8/12.76/14.1 ms', type: 'success' }
|
||||
);
|
||||
} else if (cleanCmd === 'cat server.js') {
|
||||
updatedLines.push(
|
||||
{ text: 'const express = require("express");', type: 'output' },
|
||||
{ text: 'const app = express();', type: 'output' },
|
||||
{ text: 'const PORT = process.env.PORT || 3000;', type: 'output' },
|
||||
{ text: 'app.get("/api/health", (req, res) => res.send({ status: "healthy" }));', type: 'output' },
|
||||
{ text: 'app.listen(PORT, () => console.log("Server active on cluster ingress"));', type: 'success' }
|
||||
);
|
||||
} else if (cleanCmd === 'keychain') {
|
||||
updatedLines.push(
|
||||
{ text: 'Keychain Credential Mapping Selected:', type: 'success' },
|
||||
{ text: ` Active Key: id_ed25519_alex (ED25519 standard)`, type: 'output' },
|
||||
{ text: ' Encryption: AES-256 GCM cryptokey payload', type: 'output' },
|
||||
{ text: ' Fingerprint: SHA256:7mP9K9+fVj5bW0vQ8zD1y2u3t4m5n6p7q8r9s0v1w2x', type: 'output' }
|
||||
);
|
||||
} else {
|
||||
updatedLines.push(
|
||||
{ text: `hostkeeper: command not found: "${cmd}". Type "help" to view custom commands list.`, type: 'error' }
|
||||
);
|
||||
}
|
||||
|
||||
setHistories(prev => ({ ...prev, [activeTabId]: updatedLines }));
|
||||
setCommandInput('');
|
||||
};
|
||||
|
||||
const handleCreateTab = () => {
|
||||
const title = prompt('Enter moniker or IP for the new tab:');
|
||||
if (!title) return;
|
||||
|
||||
const newId = Date.now().toString();
|
||||
const newTab: Tab = {
|
||||
id: newId,
|
||||
title: title.toLowerCase(),
|
||||
host: '192.168.1.' + Math.floor(Math.random() * 254 + 1)
|
||||
};
|
||||
|
||||
setTabs([...tabs, newTab]);
|
||||
setHistories(prev => ({
|
||||
...prev,
|
||||
[newId]: [
|
||||
{ text: `Spawning shell on customized terminal tab "${newTab.title}"...`, type: 'output' },
|
||||
{ text: `Target host identifier: ${newTab.host}`, type: 'output' },
|
||||
{ text: 'Type "help" to review simulation options.', type: 'success' }
|
||||
]
|
||||
}));
|
||||
setActiveTabId(newId);
|
||||
};
|
||||
|
||||
const handleCloseTab = (id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (tabs.length === 1) return; // Keep at least one tab
|
||||
|
||||
const remaining = tabs.filter(t => t.id !== id);
|
||||
setTabs(remaining);
|
||||
if (activeTabId === id) {
|
||||
setActiveTabId(remaining[0].id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 md:p-8 max-w-7xl mx-auto space-y-6 animate-fade-in flex flex-col h-[calc(100vh-120px)]">
|
||||
{/* Session Tab Toolbar */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-outline-variant/30 pb-3">
|
||||
<div className="flex items-center gap-2 overflow-x-auto no-scrollbar scroll-smooth pr-6">
|
||||
{tabs.map(tab => (
|
||||
<div
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTabId(tab.id)}
|
||||
className={`flex items-center gap-2 px-3.5 py-2 rounded-t-xl text-xs font-bold font-mono transition-all cursor-pointer border-t-2 ${activeTabId === tab.id ? 'bg-white text-primary border-primary shadow-[0_-4px_12px_rgba(0,80,203,0.06)]' : 'bg-surface-container-low text-on-surface-variant hover:bg-surface-container border-transparent'}`}
|
||||
>
|
||||
<TerminalSquare className={`w-3.5 h-3.5 ${activeTabId === tab.id ? 'text-primary' : 'text-outline'}`} />
|
||||
<span className="truncate max-w-[120px]">{tab.title}</span>
|
||||
{tabs.length > 1 && (
|
||||
<button
|
||||
onClick={(e) => handleCloseTab(tab.id, e)}
|
||||
className="p-0.5 hover:bg-black/10 rounded transition-colors"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={handleCreateTab}
|
||||
className="p-1.5 bg-surface-container hover:bg-primary-container hover:text-on-primary-container text-on-surface-variant rounded-lg transition-all"
|
||||
title="Open new terminal connection"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs font-mono shrink-0">
|
||||
<div className="flex items-center gap-1.5 text-secondary">
|
||||
<span className="w-2 h-2 rounded-full bg-secondary animate-pulse"></span>
|
||||
<span>SECURE CRYPTO PATH</span>
|
||||
</div>
|
||||
<div className="text-outline">
|
||||
HOST: <span className="text-on-surface font-semibold">{activeTab.host}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terminal shell frame */}
|
||||
<div className="flex-1 bg-white border border-outline-variant rounded-2xl shadow-sm flex flex-col overflow-hidden">
|
||||
{/* Terminal Header Accessories */}
|
||||
<div className="px-4 py-2 bg-surface-container-low border-b border-outline-variant/30 flex justify-between items-center">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-3 h-3 rounded-full bg-error/80"></span>
|
||||
<span className="w-3 h-3 rounded-full bg-primary-container/80"></span>
|
||||
<span className="w-3 h-3 rounded-full bg-secondary/80"></span>
|
||||
<span className="text-[10px] font-mono font-bold text-outline ml-2">ssh -i id_ed25519 root@{activeTab.host}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setHistories(prev => ({
|
||||
...prev,
|
||||
[activeTabId]: [
|
||||
{ text: 'Session log display buffer cleared manually.', type: 'output' },
|
||||
{ text: 'Type "help" to view interactive choices.', type: 'success' }
|
||||
]
|
||||
}));
|
||||
}}
|
||||
className="text-[10px] uppercase font-bold text-outline hover:text-primary transition-colors flex items-center gap-1"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3" /> Clear Buffer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Console display logs area */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto p-5 font-mono text-sm leading-relaxed space-y-2.5 custom-scrollbar bg-white"
|
||||
>
|
||||
{activeHistory.map((line, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`whitespace-pre-wrap ${
|
||||
line.type === 'input' ? 'text-on-surface font-bold' :
|
||||
line.type === 'error' ? 'text-error bg-error-container/20 px-2.5 py-1 rounded-md' :
|
||||
line.type === 'success' ? 'text-[#006e2f] bg-secondary-container/10 px-2.5 py-1 rounded-md font-semibold' :
|
||||
'text-[#2244aa]'
|
||||
}`}
|
||||
>
|
||||
{line.text}
|
||||
</div>
|
||||
))}
|
||||
{/* Virtual caret cursor anchor */}
|
||||
<div className="h-4"></div>
|
||||
</div>
|
||||
|
||||
{/* Input prompt line form */}
|
||||
<form
|
||||
onSubmit={handleCommandSubmit}
|
||||
className="p-3 border-t border-outline-variant/40 bg-surface-container-low flex items-center gap-2.5"
|
||||
>
|
||||
<span className="font-mono text-xs font-bold text-primary pl-2 shrink-0 flex items-center gap-1">
|
||||
<CornerDownLeft className="w-3.5 h-3.5" />
|
||||
root@{activeTab.title}:~$
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
autoFocus
|
||||
value={commandInput}
|
||||
onChange={(e) => setCommandInput(e.target.value)}
|
||||
placeholder='Type a command (e.g. "help", "ls", "docker ps", "ping google.com")...'
|
||||
className="flex-1 bg-transparent border-none font-mono text-xs font-medium focus:ring-0 focus:outline-none text-on-surface p-0"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="p-1.5 bg-primary text-white rounded-lg hover:bg-primary-container hover:text-on-primary-container transition-all"
|
||||
title="Execute Command"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Bottom shortcut keys rail */}
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] font-mono font-bold text-on-surface-variant">
|
||||
<span className="text-outline">SHORTCUT SUGGESTIONS:</span>
|
||||
{['help', 'ls', 'docker ps', 'uname -a', 'ping 8.8.8.8', 'cat server.js', 'keychain', 'clear'].map(cmd => (
|
||||
<button
|
||||
key={cmd}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCommandInput(cmd);
|
||||
}}
|
||||
className="px-2.5 py-1 rounded-md bg-surface-container border border-outline-variant/30 hover:border-primary hover:bg-primary-container/10 transition-colors"
|
||||
>
|
||||
{cmd}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-primary: #0050cb;
|
||||
--color-on-primary: #ffffff;
|
||||
--color-primary-container: #dae1ff;
|
||||
--color-on-primary-container: #001849;
|
||||
|
||||
--color-secondary: #006e2f;
|
||||
--color-on-secondary: #ffffff;
|
||||
--color-secondary-container: #6bff8f;
|
||||
--color-on-secondary-container: #007432;
|
||||
|
||||
--color-tertiary: #7e23cc;
|
||||
--color-on-tertiary: #ffffff;
|
||||
--color-tertiary-container: #9944e7;
|
||||
--color-on-tertiary-container: #fef5ff;
|
||||
|
||||
--color-surface-container-lowest: #ffffff;
|
||||
--color-surface-container-low: #f2f4f6;
|
||||
--color-surface-container: #eceef0;
|
||||
--color-surface-container-high: #e6e8ea;
|
||||
--color-surface-container-highest: #e0e3e5;
|
||||
|
||||
--color-surface: #f7f9fb;
|
||||
--color-on-surface: #191c1e;
|
||||
--color-on-surface-variant: #424656;
|
||||
--color-background: #f7f9fb;
|
||||
--color-on-background: #191c1e;
|
||||
--color-outline: #727687;
|
||||
--color-outline-variant: #c2c6d8;
|
||||
|
||||
--color-error: #ba1a1a;
|
||||
--color-on-error: #ffffff;
|
||||
--color-error-container: #ffdad6;
|
||||
--color-on-error-container: #93000a;
|
||||
|
||||
--color-on-tertiary-fixed-variant: #6900b3;
|
||||
--color-secondary-fixed-dim: #4ae176;
|
||||
--color-surface-bright: #f7f9fb;
|
||||
--color-on-primary-fixed: #001849;
|
||||
--color-on-tertiary-fixed: #2c0051;
|
||||
--color-primary-fixed: #dae1ff;
|
||||
--color-on-primary-fixed-variant: #003fa4;
|
||||
--color-secondary-fixed: #6bff8f;
|
||||
--color-surface-tint: #0054d6;
|
||||
--color-inverse-surface: #2d3133;
|
||||
--color-on-secondary-fixed: #002109;
|
||||
--color-on-secondary-fixed-variant: #005321;
|
||||
--color-inverse-primary: #b3c5ff;
|
||||
--color-tertiary-fixed: #f0dbff;
|
||||
--color-primary-fixed-dim: #b3c5ff;
|
||||
--color-surface-dim: #d8dadc;
|
||||
--color-tertiary-fixed-dim: #ddb7ff;
|
||||
--color-inverse-on-surface: #eff1f3;
|
||||
|
||||
/* Fonts */
|
||||
--font-sans: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
/* Base custom styles */
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-on-background);
|
||||
}
|
||||
|
||||
.dot-grid {
|
||||
background-color: #f7f9fb;
|
||||
background-image: radial-gradient(#e2e8f0 1.5px, transparent 1.5px);
|
||||
background-size: 16px 16px;
|
||||
}
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Frosted glass styles */
|
||||
.glass-panel {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.glass-sidebar {
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
/* Custom scrollbar for modern developer feel */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
/* Micro-animations */
|
||||
@keyframes cursor-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
.terminal-cursor {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 18px;
|
||||
background-color: var(--color-primary);
|
||||
vertical-align: middle;
|
||||
animation: cursor-blink 1s infinite;
|
||||
}
|
||||
|
||||
/* Hide scrollbar utility class */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import {StrictMode} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,61 @@
|
||||
export type ViewType = 'hosts' | 'sftp' | 'snippets' | 'keychain' | 'terminal' | 'settings';
|
||||
|
||||
export interface Host {
|
||||
id: string;
|
||||
name: string;
|
||||
ip: string;
|
||||
os: 'Ubuntu 22.04' | 'Debian 11' | 'Windows Server' | 'macOS Ventura' | 'CentOS 7' | 'Alpine Linux';
|
||||
provider: 'AWS US-East' | 'GCP Cloud' | 'Hetzner' | 'Vercel Proxy' | 'Local Docker' | 'Bare Metal';
|
||||
status: 'active' | 'offline';
|
||||
lastSeen: string;
|
||||
type: 'api' | 'db' | 'edge' | 'web' | 'desktop' | 'server';
|
||||
}
|
||||
|
||||
export interface FileItem {
|
||||
name: string;
|
||||
size: string;
|
||||
modified: string;
|
||||
type: 'folder' | 'file' | 'image' | 'code' | 'lock';
|
||||
extension?: string;
|
||||
}
|
||||
|
||||
export interface Snippet {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
code: string;
|
||||
tags: string[];
|
||||
collection: 'favorites' | 'recent' | 'trash';
|
||||
updatedAt: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export interface KeychainItem {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'SSH RSA' | 'PASSWORD' | 'ED25519' | 'Bearer Token' | 'AWS Key';
|
||||
user: string;
|
||||
lastUsed: string;
|
||||
strength: 'secure' | 'weak' | 'moderate';
|
||||
fingerprint?: string;
|
||||
passphrase?: string;
|
||||
connectedHosts?: string[];
|
||||
keyFile?: string;
|
||||
}
|
||||
|
||||
export interface Device {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'desktop' | 'tablet' | 'mobile';
|
||||
status: 'online' | 'offline';
|
||||
}
|
||||
|
||||
export interface BackgroundTransfer {
|
||||
id: string;
|
||||
fileName: string;
|
||||
source: string;
|
||||
destination: string;
|
||||
progress: number;
|
||||
speed: string;
|
||||
type: 'upload' | 'download';
|
||||
}
|
||||
Reference in New Issue
Block a user