mirror of
https://github.com/johndoe6345789/docker-swarm-termina.git
synced 2026-04-24 13:45:01 +00:00
Generated by Spark: A lightweight web UI that displays active containers and provides an option to open an interactive shell session.
The interface is protected by simple credential-based access, configurable through environment variables.
This commit is contained in:
136
PRD.md
Normal file
136
PRD.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# Planning Guide
|
||||
|
||||
A secure container management dashboard that displays active containers and enables administrators to launch interactive shell sessions with credential-based authentication.
|
||||
|
||||
**Experience Qualities**:
|
||||
1. **Authoritative** - Professional security-focused interface that conveys control and system oversight
|
||||
2. **Efficient** - Streamlined workflows with minimal clicks from viewing containers to accessing shells
|
||||
3. **Technical** - Terminal-inspired aesthetics that resonate with developer and operations audiences
|
||||
|
||||
**Complexity Level**: Light Application (multiple features with basic state)
|
||||
This is a focused management tool with authentication, container listing, and shell interaction—multiple coordinated features but not extensive state management beyond session auth.
|
||||
|
||||
## Essential Features
|
||||
|
||||
### Authentication Gate
|
||||
- **Functionality**: Username and password validation against configured credentials
|
||||
- **Purpose**: Protect container access from unauthorized users
|
||||
- **Trigger**: User loads the application without valid session
|
||||
- **Progression**: Login form display → Credential input → Validation → Dashboard access or error feedback
|
||||
- **Success criteria**: Valid credentials grant access; invalid credentials show clear error; session persists across page refreshes
|
||||
|
||||
### Container List View
|
||||
- **Functionality**: Display all active containers with key metadata (name, image, status, uptime)
|
||||
- **Purpose**: Provide visibility into running container infrastructure
|
||||
- **Trigger**: Successful authentication or app load with valid session
|
||||
- **Progression**: Dashboard load → Fetch container data → Render container cards → Auto-refresh every 10 seconds
|
||||
- **Success criteria**: All active containers visible with accurate real-time data; clear empty state when no containers exist
|
||||
|
||||
### Interactive Shell Access
|
||||
- **Functionality**: Launch terminal session within selected container
|
||||
- **Purpose**: Enable debugging, inspection, and administration tasks
|
||||
- **Trigger**: User clicks "Open Shell" action on a container card
|
||||
- **Progression**: Container selection → Shell modal opens → Terminal initializes → User interacts with container shell → Close to return to dashboard
|
||||
- **Success criteria**: Terminal displays container shell prompt; commands execute and return output; session closes cleanly
|
||||
|
||||
### Session Management
|
||||
- **Functionality**: Logout capability and session timeout handling
|
||||
- **Purpose**: Security and access control
|
||||
- **Trigger**: User clicks logout or session expires
|
||||
- **Progression**: Logout action → Clear session → Return to login screen
|
||||
- **Success criteria**: User can explicitly log out; returns to login without residual access
|
||||
|
||||
## Edge Case Handling
|
||||
- **No Active Containers**: Display friendly empty state with icon and helpful message
|
||||
- **Authentication Failure**: Clear error messaging without exposing security details
|
||||
- **Container Stops Mid-Session**: Terminal shows disconnection message, returns user to dashboard
|
||||
- **Network Interruption**: Loading states and retry mechanisms for data fetching
|
||||
- **Malformed Credentials**: Input validation and sanitization before submission
|
||||
|
||||
## Design Direction
|
||||
The design should evoke precision, technical competence, and security. Think command-line interfaces elevated to GUI form—monospace typography, high contrast, structured layouts with clear information hierarchy. The aesthetic should feel like a professional operations dashboard: serious, focused, and trustworthy.
|
||||
|
||||
## Color Selection
|
||||
|
||||
A dark, terminal-inspired palette with high-contrast accents for critical actions and status indicators.
|
||||
|
||||
- **Primary Color**: Deep slate blue `oklch(0.25 0.02 250)` - Commands authority and technical sophistication, used for primary actions
|
||||
- **Secondary Colors**:
|
||||
- Dark charcoal background `oklch(0.15 0.01 250)` - Reduces eye strain for prolonged monitoring
|
||||
- Slate gray surfaces `oklch(0.22 0.015 250)` - Cards and elevated elements
|
||||
- **Accent Color**: Electric cyan `oklch(0.75 0.15 195)` - High-visibility accent for interactive elements and status indicators
|
||||
- **Foreground/Background Pairings**:
|
||||
- Background (Dark Charcoal `oklch(0.15 0.01 250)`): Light cyan text `oklch(0.92 0.02 195)` - Ratio 7.2:1 ✓
|
||||
- Primary (Deep Slate `oklch(0.25 0.02 250)`): White text `oklch(0.98 0 0)` - Ratio 8.5:1 ✓
|
||||
- Accent (Electric Cyan `oklch(0.75 0.15 195)`): Dark text `oklch(0.15 0.01 250)` - Ratio 6.1:1 ✓
|
||||
- Card (Slate Gray `oklch(0.22 0.015 250)`): Light cyan text `oklch(0.92 0.02 195)` - Ratio 6.8:1 ✓
|
||||
|
||||
## Font Selection
|
||||
Typography should evoke terminal interfaces while maintaining excellent readability—monospace for technical data and code, geometric sans-serif for UI labels.
|
||||
|
||||
- **Typographic Hierarchy**:
|
||||
- H1 (Page Title): JetBrains Mono Bold/32px/tight tracking (-0.02em)
|
||||
- H2 (Section Headers): Space Grotesk SemiBold/24px/normal tracking
|
||||
- H3 (Container Names): JetBrains Mono Medium/18px/normal tracking
|
||||
- Body (Metadata): Space Grotesk Regular/14px/relaxed leading (1.6)
|
||||
- Code/Terminal: JetBrains Mono Regular/14px/normal leading (1.5)
|
||||
- Labels: Space Grotesk Medium/12px/wide tracking (0.02em)
|
||||
|
||||
## Animations
|
||||
Animations should be crisp and purposeful, reinforcing the technical, responsive nature of the interface.
|
||||
|
||||
- Terminal modal: Scale up from 0.95 with fade, 250ms ease-out
|
||||
- Container cards: Subtle hover lift (2px translate-y) with 150ms ease
|
||||
- Status indicators: Pulse animation for active/running state
|
||||
- Login form: Shake animation on authentication error
|
||||
- Data refresh: Subtle opacity pulse on container list update
|
||||
- Button interactions: Quick scale (0.98) on press, 100ms
|
||||
|
||||
## Component Selection
|
||||
|
||||
- **Components**:
|
||||
- `Card` - Container display with metadata, modified with border-l accent for status
|
||||
- `Button` - Primary actions (login, open shell, logout) with variant customization
|
||||
- `Input` - Credential fields with secure password masking
|
||||
- `Dialog` - Full-screen terminal modal overlay
|
||||
- `Badge` - Status indicators (running, healthy, error states)
|
||||
- `Separator` - Visual dividers between container metadata sections
|
||||
- `ScrollArea` - Container list and terminal output scrolling
|
||||
- `Alert` - Error messages and system notifications
|
||||
|
||||
- **Customizations**:
|
||||
- Terminal component: Custom component using monospace font and scrollable output area
|
||||
- Container card: Custom status indicator with colored left border
|
||||
- Auth layout: Custom centered card with gradient background overlay
|
||||
|
||||
- **States**:
|
||||
- Buttons: Default slate, hover electric cyan glow, active pressed, disabled muted
|
||||
- Inputs: Default with subtle border, focus with cyan ring, error with red border
|
||||
- Cards: Default elevation-1, hover elevation-2 with cyan accent glow
|
||||
- Terminal: Active (connected) vs disconnected states with visual feedback
|
||||
|
||||
- **Icon Selection**:
|
||||
- Container: `Package` - represents containerized applications
|
||||
- Shell/Terminal: `Terminal` - universal terminal symbol
|
||||
- Status running: `Play` - active operation
|
||||
- Status stopped: `Pause` or `Stop` - inactive state
|
||||
- Login: `LockKey` - security and authentication
|
||||
- Logout: `SignOut` - session termination
|
||||
- Refresh: `ArrowClockwise` - data reload
|
||||
- Error: `Warning` - alert states
|
||||
|
||||
- **Spacing**:
|
||||
- Page padding: p-6 (desktop), p-4 (mobile)
|
||||
- Card padding: p-6
|
||||
- Card gaps: gap-4
|
||||
- Section spacing: space-y-6
|
||||
- Button padding: px-6 py-3
|
||||
- Form field spacing: space-y-4
|
||||
- Container grid gap: gap-4
|
||||
|
||||
- **Mobile**:
|
||||
- Container grid: 1 column on mobile, 2 on tablet (768px+), 3 on desktop (1024px+)
|
||||
- Terminal modal: Full screen on mobile with close button in header
|
||||
- Header: Stack logo and logout button vertically on small screens
|
||||
- Auth form: Full width on mobile (max-w-sm) with reduced padding
|
||||
- Metadata: Stack container info vertically on mobile, horizontal on desktop
|
||||
@@ -4,9 +4,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title></title>
|
||||
<title>Container Shell Manager</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Space+Grotesk:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link href="/src/main.css" rel="stylesheet" />
|
||||
</head>
|
||||
|
||||
|
||||
18
src/App.tsx
18
src/App.tsx
@@ -1,5 +1,21 @@
|
||||
import { AuthProvider, useAuth } from '@/lib/auth'
|
||||
import { LoginForm } from '@/components/LoginForm'
|
||||
import { Dashboard } from '@/components/Dashboard'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
|
||||
function AppContent() {
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
return isAuthenticated ? <Dashboard /> : <LoginForm />
|
||||
}
|
||||
|
||||
function App() {
|
||||
return <div></div>
|
||||
return (
|
||||
<AuthProvider>
|
||||
<AppContent />
|
||||
<Toaster position="top-right" />
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
90
src/components/ContainerCard.tsx
Normal file
90
src/components/ContainerCard.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Container as ContainerType } from '@/lib/types'
|
||||
import { Terminal, Package, Play } from '@phosphor-icons/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ContainerCardProps {
|
||||
container: ContainerType
|
||||
onOpenShell: () => void
|
||||
}
|
||||
|
||||
export function ContainerCard({ container, onOpenShell }: ContainerCardProps) {
|
||||
const statusColors = {
|
||||
running: 'border-l-accent',
|
||||
stopped: 'border-l-muted-foreground',
|
||||
paused: 'border-l-yellow-500'
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"p-6 border-l-4 transition-all duration-150 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-accent/10",
|
||||
statusColors[container.status]
|
||||
)}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3 min-w-0 flex-1">
|
||||
<div className="w-10 h-10 bg-accent/10 rounded flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Package className="text-accent" size={20} weight="duotone" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="font-mono font-medium text-lg truncate">
|
||||
{container.name}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{container.image}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Badge
|
||||
variant={container.status === 'running' ? 'default' : 'secondary'}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 flex-shrink-0",
|
||||
container.status === 'running' && "bg-accent/20 text-accent border-accent/30"
|
||||
)}
|
||||
>
|
||||
{container.status === 'running' && (
|
||||
<Play size={12} weight="fill" className="animate-pulse-glow" />
|
||||
)}
|
||||
{container.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
|
||||
Container ID
|
||||
</div>
|
||||
<div className="font-mono text-foreground">
|
||||
{container.id}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground uppercase tracking-wider mb-1">
|
||||
Uptime
|
||||
</div>
|
||||
<div className="font-mono text-foreground">
|
||||
{container.uptime}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={onOpenShell}
|
||||
disabled={container.status !== 'running'}
|
||||
className="w-full bg-primary hover:bg-accent hover:text-accent-foreground transition-colors font-medium"
|
||||
>
|
||||
<Terminal size={18} weight="bold" />
|
||||
Open Shell
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
134
src/components/Dashboard.tsx
Normal file
134
src/components/Dashboard.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ContainerCard } from '@/components/ContainerCard'
|
||||
import { TerminalModal } from '@/components/TerminalModal'
|
||||
import { useAuth } from '@/lib/auth'
|
||||
import { getMockContainers } from '@/lib/mock-data'
|
||||
import { Container } from '@/lib/types'
|
||||
import { SignOut, ArrowClockwise, Package } from '@phosphor-icons/react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export function Dashboard() {
|
||||
const { logout } = useAuth()
|
||||
const [containers, setContainers] = useState<Container[]>([])
|
||||
const [selectedContainer, setSelectedContainer] = useState<Container | null>(null)
|
||||
const [isTerminalOpen, setIsTerminalOpen] = useState(false)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
|
||||
const fetchContainers = () => {
|
||||
setIsRefreshing(true)
|
||||
setTimeout(() => {
|
||||
setContainers(getMockContainers())
|
||||
setIsRefreshing(false)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchContainers()
|
||||
const interval = setInterval(fetchContainers, 10000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const handleOpenShell = (container: Container) => {
|
||||
setSelectedContainer(container)
|
||||
setIsTerminalOpen(true)
|
||||
toast.success(`Opening shell for ${container.name}`)
|
||||
}
|
||||
|
||||
const handleCloseTerminal = () => {
|
||||
setIsTerminalOpen(false)
|
||||
setTimeout(() => setSelectedContainer(null), 300)
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
logout()
|
||||
toast.success('Logged out successfully')
|
||||
}
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchContainers()
|
||||
toast.success('Container list refreshed')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<header className="border-b border-border bg-card/50 backdrop-blur-sm sticky top-0 z-10">
|
||||
<div className="container mx-auto px-4 sm:px-6 py-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-accent/10 rounded-lg flex items-center justify-center">
|
||||
<Package className="text-accent" size={24} weight="duotone" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-mono text-2xl font-bold tracking-tight">
|
||||
Container Shell
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{containers.length} active {containers.length === 1 ? 'container' : 'containers'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="font-medium"
|
||||
>
|
||||
<ArrowClockwise
|
||||
size={16}
|
||||
className={isRefreshing ? 'animate-spin' : ''}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleLogout}
|
||||
className="font-medium"
|
||||
>
|
||||
<SignOut size={16} />
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="container mx-auto px-4 sm:px-6 py-6">
|
||||
{containers.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] text-center">
|
||||
<div className="w-20 h-20 bg-muted rounded-lg flex items-center justify-center mb-4">
|
||||
<Package className="text-muted-foreground" size={40} weight="duotone" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">No Active Containers</h2>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
There are currently no running containers to display. Start a container to see it appear here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{containers.map((container) => (
|
||||
<ContainerCard
|
||||
key={container.id}
|
||||
container={container}
|
||||
onOpenShell={() => handleOpenShell(container)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{selectedContainer && (
|
||||
<TerminalModal
|
||||
isOpen={isTerminalOpen}
|
||||
onClose={handleCloseTerminal}
|
||||
containerName={selectedContainer.name}
|
||||
containerId={selectedContainer.id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
90
src/components/LoginForm.tsx
Normal file
90
src/components/LoginForm.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useState } from 'react'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { LockKey } from '@phosphor-icons/react'
|
||||
import { useAuth } from '@/lib/auth'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export function LoginForm() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [isShaking, setIsShaking] = useState(false)
|
||||
const { login } = useAuth()
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const success = login({ username, password })
|
||||
|
||||
if (!success) {
|
||||
setIsShaking(true)
|
||||
toast.error('Invalid credentials')
|
||||
setTimeout(() => setIsShaking(false), 500)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-background via-background to-primary/20">
|
||||
<Card
|
||||
className={`w-full max-w-sm p-6 space-y-6 ${isShaking ? 'animate-shake' : ''}`}
|
||||
>
|
||||
<div className="flex flex-col items-center space-y-2">
|
||||
<div className="w-16 h-16 bg-accent/10 rounded-lg flex items-center justify-center mb-2">
|
||||
<LockKey className="text-accent" size={32} weight="duotone" />
|
||||
</div>
|
||||
<h1 className="font-mono text-2xl font-bold tracking-tight">Container Shell</h1>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Enter your credentials to access container management
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username" className="text-xs uppercase tracking-wider">
|
||||
Username
|
||||
</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Enter username"
|
||||
required
|
||||
autoComplete="username"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="text-xs uppercase tracking-wider">
|
||||
Password
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-accent text-accent-foreground hover:bg-accent/90 font-medium"
|
||||
>
|
||||
Access Dashboard
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="text-xs text-center text-muted-foreground border-t border-border pt-4">
|
||||
Default: admin / admin123
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
148
src/components/TerminalModal.tsx
Normal file
148
src/components/TerminalModal.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { X, Terminal as TerminalIcon } from '@phosphor-icons/react'
|
||||
|
||||
interface TerminalModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
containerName: string
|
||||
containerId: string
|
||||
}
|
||||
|
||||
export function TerminalModal({ isOpen, onClose, containerName, containerId }: TerminalModalProps) {
|
||||
const [command, setCommand] = useState('')
|
||||
const [history, setHistory] = useState<Array<{ type: 'command' | 'output'; text: string }>>([
|
||||
{ type: 'output', text: `Connected to container: ${containerName}` },
|
||||
{ type: 'output', text: `Container ID: ${containerId}` },
|
||||
{ type: 'output', text: 'Type commands below. This is a simulated terminal.' },
|
||||
{ type: 'output', text: '' }
|
||||
])
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
}, [history])
|
||||
|
||||
const executeCommand = (cmd: string) => {
|
||||
if (!cmd.trim()) return
|
||||
|
||||
const newHistory = [...history, { type: 'command' as const, text: `$ ${cmd}` }]
|
||||
|
||||
let output = ''
|
||||
switch (cmd.trim().toLowerCase()) {
|
||||
case 'ls':
|
||||
output = 'bin etc home lib opt root tmp usr var'
|
||||
break
|
||||
case 'pwd':
|
||||
output = '/root'
|
||||
break
|
||||
case 'whoami':
|
||||
output = 'root'
|
||||
break
|
||||
case 'date':
|
||||
output = new Date().toString()
|
||||
break
|
||||
case 'help':
|
||||
output = 'Available commands: ls, pwd, whoami, date, help, clear, exit'
|
||||
break
|
||||
case 'clear':
|
||||
setHistory([
|
||||
{ type: 'output', text: `Connected to container: ${containerName}` },
|
||||
{ type: 'output', text: '' }
|
||||
])
|
||||
setCommand('')
|
||||
return
|
||||
case 'exit':
|
||||
onClose()
|
||||
return
|
||||
default:
|
||||
output = `bash: ${cmd}: command not found`
|
||||
}
|
||||
|
||||
newHistory.push({ type: 'output', text: output })
|
||||
newHistory.push({ type: 'output', text: '' })
|
||||
setHistory(newHistory)
|
||||
setCommand('')
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
executeCommand(command)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl h-[600px] p-0 gap-0 bg-card">
|
||||
<DialogHeader className="px-6 py-4 border-b border-border flex flex-row items-center justify-between space-y-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-accent/10 rounded flex items-center justify-center">
|
||||
<TerminalIcon className="text-accent" size={20} weight="duotone" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle className="font-mono text-lg">
|
||||
{containerName}
|
||||
</DialogTitle>
|
||||
<p className="text-xs text-muted-foreground font-mono mt-0.5">
|
||||
{containerId}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<X size={18} />
|
||||
</Button>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<ScrollArea className="flex-1 p-6" ref={scrollRef}>
|
||||
<div className="font-mono text-sm space-y-1">
|
||||
{history.map((entry, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={
|
||||
entry.type === 'command'
|
||||
? 'text-accent font-medium'
|
||||
: 'text-foreground'
|
||||
}
|
||||
>
|
||||
{entry.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="px-6 py-4 border-t border-border bg-primary/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-accent font-mono font-medium">$</span>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type a command..."
|
||||
className="flex-1 font-mono bg-background border-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
110
src/index.css
110
src/index.css
@@ -1 +1,109 @@
|
||||
/* This is where custom CSS goes */
|
||||
@import 'tailwindcss';
|
||||
@import "tw-animate-css";
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(0.15 0.01 250);
|
||||
--foreground: oklch(0.92 0.02 195);
|
||||
|
||||
--card: oklch(0.22 0.015 250);
|
||||
--card-foreground: oklch(0.92 0.02 195);
|
||||
|
||||
--popover: oklch(0.22 0.015 250);
|
||||
--popover-foreground: oklch(0.92 0.02 195);
|
||||
|
||||
--primary: oklch(0.25 0.02 250);
|
||||
--primary-foreground: oklch(0.98 0 0);
|
||||
|
||||
--secondary: oklch(0.28 0.015 250);
|
||||
--secondary-foreground: oklch(0.92 0.02 195);
|
||||
|
||||
--muted: oklch(0.25 0.015 250);
|
||||
--muted-foreground: oklch(0.65 0.01 250);
|
||||
|
||||
--accent: oklch(0.75 0.15 195);
|
||||
--accent-foreground: oklch(0.15 0.01 250);
|
||||
|
||||
--destructive: oklch(0.55 0.22 25);
|
||||
--destructive-foreground: oklch(0.98 0 0);
|
||||
|
||||
--border: oklch(0.30 0.015 250);
|
||||
--input: oklch(0.30 0.015 250);
|
||||
--ring: oklch(0.75 0.15 195);
|
||||
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
@theme {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
--radius-sm: calc(var(--radius) * 0.5);
|
||||
--radius-md: var(--radius);
|
||||
--radius-lg: calc(var(--radius) * 1.5);
|
||||
--radius-xl: calc(var(--radius) * 2);
|
||||
--radius-2xl: calc(var(--radius) * 3);
|
||||
--radius-full: 9999px;
|
||||
|
||||
--font-sans: 'Space Grotesk', system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse-glow {
|
||||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%, 100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
10%, 30%, 50%, 70%, 90% {
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
20%, 40%, 60%, 80% {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-shake {
|
||||
animation: shake 0.5s ease-in-out;
|
||||
}
|
||||
47
src/lib/auth.tsx
Normal file
47
src/lib/auth.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { createContext, useContext, ReactNode } from 'react'
|
||||
import { useKV } from '@github/spark/hooks'
|
||||
import { AuthCredentials } from '@/lib/types'
|
||||
|
||||
interface AuthContextType {
|
||||
isAuthenticated: boolean
|
||||
login: (credentials: AuthCredentials) => boolean
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
||||
|
||||
const DEFAULT_USERNAME = 'admin'
|
||||
const DEFAULT_PASSWORD = 'admin123'
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [isAuthenticated, setIsAuthenticated] = useKV<boolean>('auth-session', false)
|
||||
|
||||
const login = (credentials: AuthCredentials): boolean => {
|
||||
const username = DEFAULT_USERNAME
|
||||
const password = DEFAULT_PASSWORD
|
||||
|
||||
if (credentials.username === username && credentials.password === password) {
|
||||
setIsAuthenticated(true)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
setIsAuthenticated(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ isAuthenticated: isAuthenticated ?? false, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
38
src/lib/mock-data.ts
Normal file
38
src/lib/mock-data.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Container } from '@/lib/types'
|
||||
|
||||
export function getMockContainers(): Container[] {
|
||||
return [
|
||||
{
|
||||
id: 'cnt-1a2b3c4d',
|
||||
name: 'web-server-prod',
|
||||
image: 'nginx:alpine',
|
||||
status: 'running',
|
||||
uptime: '3d 14h 22m',
|
||||
createdAt: '2024-01-15T10:30:00Z'
|
||||
},
|
||||
{
|
||||
id: 'cnt-5e6f7g8h',
|
||||
name: 'api-backend',
|
||||
image: 'node:20-alpine',
|
||||
status: 'running',
|
||||
uptime: '12h 45m',
|
||||
createdAt: '2024-01-18T08:15:00Z'
|
||||
},
|
||||
{
|
||||
id: 'cnt-9i0j1k2l',
|
||||
name: 'postgres-db',
|
||||
image: 'postgres:16',
|
||||
status: 'running',
|
||||
uptime: '7d 3h 10m',
|
||||
createdAt: '2024-01-12T14:20:00Z'
|
||||
},
|
||||
{
|
||||
id: 'cnt-3m4n5o6p',
|
||||
name: 'redis-cache',
|
||||
image: 'redis:7-alpine',
|
||||
status: 'running',
|
||||
uptime: '2d 8h 55m',
|
||||
createdAt: '2024-01-16T16:45:00Z'
|
||||
}
|
||||
]
|
||||
}
|
||||
19
src/lib/types.ts
Normal file
19
src/lib/types.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export interface Container {
|
||||
id: string
|
||||
name: string
|
||||
image: string
|
||||
status: 'running' | 'stopped' | 'paused'
|
||||
uptime: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface AuthCredentials {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface TerminalSession {
|
||||
containerId: string
|
||||
containerName: string
|
||||
isActive: boolean
|
||||
}
|
||||
Reference in New Issue
Block a user