feat: comprehensive mobile responsiveness improvements (#76)
* feat: comprehensive mobile responsiveness improvements - Made main layout responsive with proper mobile padding and spacing - Updated Terminal component with mobile-friendly controls and sizing - Enhanced VersionDisplay with responsive layout and condensed mobile text - Improved ScriptsGrid and DownloadedScriptsTab with mobile-first design - Made CategorySidebar responsive with horizontal scroll on mobile - Fixed FilterBar styling consistency and added Lucide icons - Enhanced all modals (Settings, ScriptDetail, ExecutionMode, etc.) for mobile - Updated ServerForm and ServerList with mobile-optimized layouts - Added global CSS improvements for mobile touch targets and typography - Fixed close button placement in ScriptDetailModal to follow UI conventions - Implemented responsive breakpoints throughout the application - Added proper viewport meta tag for mobile rendering All components now provide excellent user experience across all device sizes. * fix: improve mobile terminal input handling for SSH processes - Updated mobile input controls to use up/down arrows instead of numbered buttons - Fixed WebSocket input handling to support both regular processes and pty processes (SSH) - Added comprehensive debugging logs for input handling - Added visual feedback showing when inputs are sent - Improved error handling and user feedback for input failures The mobile terminal input should now work properly with SSH-executed scripts. * debug: add comprehensive debugging for mobile terminal input - Added byte-level debugging to see exact input being sent - Added test button to verify basic input works - Enhanced server-side logging to track input processing - Improved escape sequence handling for arrow keys This will help identify why mobile inputs aren't working while keyboard works. * debug: add comprehensive server-side debugging for mobile input - Added detailed logging for mobile input processing - Added confirmation messages sent back to client - Enhanced debugging to track input flow from client to server - Added JSON string representation of inputs for better debugging This will help identify why mobile inputs aren't working while keyboard works. * debug: add WebSocket message routing debugging - Added comprehensive logging for all WebSocket messages - Added specific debugging for input action handling - Added full message object logging to identify routing issues - Enhanced input action validation logging This will help identify if input messages are reaching the server at all. * debug: add comprehensive client-side debugging for mobile input - Added detailed logging for button click events - Added WebSocket connection state debugging - Added message sending confirmation logging - Enhanced sendInput function with complete debugging This will help identify if mobile buttons are being clicked and if WebSocket messages are being sent. * debug: add WebSocket connection tracking and message debugging - Added connection ID tracking for each WebSocket connection - Added detailed logging for all incoming WebSocket messages - Added connection close and error event logging - Enhanced message parsing debugging This will help identify if mobile input messages are reaching the server and which connection they're using. * fix: correct WebSocket message format for keyboard input - Fixed keyboard input to use 'data' field instead of 'input' field - Added debugging for keyboard input to compare with mobile input - Both mobile and keyboard inputs now use consistent message format - This should fix the issue where mobile inputs weren't working The server expects 'data' field but keyboard was sending 'input' field. * debug: add WebSocket connection details for mobile vs keyboard input - Added WebSocket URL and protocol logging for both mobile and keyboard input - Added WebSocket object logging to compare connections - Enhanced debugging to identify if mobile and keyboard use different WebSocket connections This will help identify if there's a connection mismatch between mobile and keyboard input. * fix: correct WebSocket message format to use 'input' field - Reverted both mobile and keyboard input to use 'input' field instead of 'data' - Updated server to expect 'input' field in WebSocket messages - Fixed server-side logging to use correct field names - This should restore both keyboard and mobile input functionality The server was actually expecting 'input' field, not 'data' field. * feat: add left/right arrow buttons to mobile terminal input - Added ChevronLeft and ChevronRight icons to imports - Added left/right navigation buttons alongside up/down buttons - Left button sends \x1b[D (ANSI escape sequence for left arrow) - Right button sends \x1b[C (ANSI escape sequence for right arrow) - Updated visual feedback to show 'Left' and 'Right' for arrow inputs - Mobile users now have full directional navigation: up, down, left, right This completes the mobile terminal navigation controls for touch devices. * feat: add spacebar button and clean up mobile terminal controls - Added spacebar button to mobile input controls - Removed 'Yes (y)' and 'Test (1)' buttons to simplify interface - Changed action buttons from 3-column to 2-column grid (Enter, Space) - Updated visual feedback to show 'Space' for spacebar input - Mobile controls now focus on essential navigation and input This streamlines the mobile terminal interface with only the most useful controls. * feat: add backspace button to mobile terminal controls - Added backspace button (⌫ Backspace) to action buttons - Sends \b character for backspace functionality - Changed action buttons from 2-column to 3-column grid - Updated visual feedback to show 'Backspace' for backspace input - Mobile users now have complete text editing capabilities This completes the essential mobile terminal input controls with navigation, text input, and editing functions. * feat: improve mobile terminal scaling and responsiveness - Reduced font size from 14px to 10px on mobile devices (< 768px width) - Set mobile-specific terminal dimensions (20 rows, 60 cols) for better fit - Reduced mobile terminal height from 20rem to 16rem (256px min-height) - Added responsive resize listener to adjust terminal size on orientation changes - Improved mobile terminal display to prevent cramped text and odd appearance - Better balance between terminal content and mobile input controls This makes the terminal much more readable and usable on mobile devices. * fix: improve ANSI escape sequence handling for whiptail dialogs - Added better ANSI handling configuration to terminal - Added detection and logging for screen clearing sequences (\x1b[2J, \x1b[H\x1b[2J) - Added detection and logging for cursor positioning sequences - Enabled allowProposedApi for better terminal compatibility - Added debugging to identify when clear screen operations occur This should fix the issue where whiptail dialogs duplicate content and don't properly clear the screen on mobile input. * debug: add whiptail/dialog detection and logging - Added detection for whiptail and dialog output in terminal messages - Added logging to track when whiptail content is being processed - This will help identify if the issue is with ANSI sequence processing - Console logs will show when clear screen, cursor positioning, and whiptail content is detected This debugging will help identify the root cause of the terminal rerendering issue. * fix: force screen clear on cursor positioning to prevent whiptail duplication - Modified output handling to force screen clear (\x1b[2J\x1b[H) when cursor positioning is detected - Removed whiptail-specific detection in favor of broader cursor positioning approach - This should prevent content duplication when whiptail redraws its interface - Cursor positioning sequences often indicate a full screen redraw is intended This aggressive approach should finally fix the terminal rerendering issue. * debug: add comprehensive output debugging and try terminal.clear() - Added detailed logging for all output messages including length, preview, and ANSI detection - Changed cursor positioning handling to use xtermRef.current.clear() for more aggressive clearing - This will help identify exactly what data is being received and how it's being processed - The terminal.clear() method should completely reset the terminal buffer This debugging will help us understand why the duplication is still occurring. * debug: add whiptail session detection and enhanced debugging - Added inWhiptailSession state to track when we're in a whiptail dialog - Added detection for whiptail/dialog content to set session flag - Enhanced output debugging with comprehensive logging - Added aggressive terminal clearing specifically for whiptail sessions - Reset whiptail session when script ends - Set explicit terminal dimensions (cols/rows) for better behavior This should provide much more detailed debugging information and better handling of whiptail sessions. * feat: improve whiptail centering on mobile devices - Reduced mobile terminal dimensions to 50 cols x 18 rows for better whiptail centering - Reduced mobile terminal height from 16rem to 14rem (224px min-height) - Added isMobile state to component level for consistent mobile detection - Added horizontal padding on mobile to help center terminal content - Smaller terminal dimensions should help whiptail dialogs appear more centered This should improve the visual positioning of whiptail dialogs on mobile devices. * feat: improve whiptail horizontal centering on mobile - Reduced mobile terminal dimensions to 40 cols x 16 rows for better centering - Added mobile-terminal CSS class with flex centering - Added CSS rules to center xterm content horizontally on mobile - Added transform translateX for fine-tuning horizontal position - Increased horizontal padding to 2rem on mobile This should better center the whiptail dialog horizontally on mobile devices. * fix: resolve text wrapping and overflow issues in mobile terminal - Increased mobile terminal dimensions to 60 cols x 20 rows to prevent text cutoff - Increased mobile terminal height back to 16rem (256px) for better content display - Added overflow: hidden to mobile terminal CSS to prevent content overflow - Added width and max-width constraints to xterm elements - Reduced horizontal padding and transform to better fit content - Cleaned up duplicate terminal configuration options This should fix the weird text wrapping and cutoff issues on mobile devices. * fix: try auto-fit approach to resolve mobile terminal text wrapping - Removed fixed terminal dimensions on mobile to let it auto-fit - Added double fit calls for mobile to ensure proper sizing - Removed restrictive CSS overflow and transform rules - Simplified terminal container styling for better auto-fitting - Let xterm.js handle the sizing automatically on mobile This approach should allow the terminal to properly fit the content without text cutoff. * fix: improve mobile terminal centering with specific dimensions - Set mobile terminal to 45 cols x 18 rows for better whiptail dialog fit - Added padding and transform to better center content on mobile - Used flex centering in terminal container for mobile - Added overflow hidden to prevent text cutoff - More aggressive centering approach for mobile devices This should better center the whiptail dialog and prevent text cutoff on mobile. * feat: implement virtual terminal overflow approach for mobile whiptail - Increased mobile virtual terminal to 80 cols x 30 rows (larger than display) - Let virtual terminal overflow and center the whiptail dialog in viewport - Added overflow: hidden to container to hide overflow content - Centered viewport to show middle portion of virtual terminal - This approach lets whiptail render at full size while showing only the center This should properly center the whiptail dialog without text wrapping or cutoff issues. * revert: simplify mobile terminal approach and reduce font size - Reverted to simpler 50 cols x 20 rows terminal dimensions - Reduced mobile font size from 10px to 8px for better fit - Simplified CSS to basic flex centering without complex overflow handling - Added multiple fit calls for mobile to ensure proper terminal sizing - Removed complex virtual terminal overflow approach that wasn't working This should provide a more stable and predictable mobile terminal display. * feat: reduce mobile terminal font size and dimensions for better fit - Reduced mobile font size from 8px to 6px for even smaller text - Reduced mobile terminal dimensions to 45 cols x 18 rows - This should provide more room for whiptail dialog content on mobile - Smaller font and dimensions should prevent text cutoff and wrapping This should finally get the whiptail dialog to fit properly on mobile devices. * feat: increase mobile font size and fix whiptail duplication - Increased mobile font size from 6px to 7px for slightly larger text - Fixed whiptail duplication by adding delay after terminal clear - Added setTimeout to ensure clear is processed before writing new content - This should prevent the weird lines/duplication while keeping good fit This should give a good balance between readability and fit while preventing duplication. * fix: implement more aggressive terminal clearing for whiptail - Added multiple clear operations: clear(), \x1b[2J\x1b[H, \x1b[3J, \x1b[2J - Added scrollback buffer clearing with \x1b[3J - Increased delay from 10ms to 50ms for better processing - Added double clear() calls to force terminal buffer clearing - This should finally eliminate the duplication lines in whiptail dialogs This aggressive approach should completely clear the terminal before redrawing whiptail content. * fix: implement terminal reset approach for whiptail duplication - Added immediate clearing when whiptail session is detected - Implemented terminal.reset() method for complete terminal state reset - Added longer 100ms delay after reset to ensure proper processing - Clear terminal immediately when whiptail content is first detected - This nuclear approach should completely eliminate duplication issues This should finally solve the persistent duplication problem by completely resetting the terminal state. * cleanup: remove all debug logging from terminal component - Removed all console.log statements from output handling - Removed debug logging from mobile input functions - Removed debug logging from keyboard input handler - Removed debug logging from all mobile button click handlers - Simplified button onClick handlers to direct function calls - Kept all functionality while removing debugging noise The terminal now has clean, production-ready code without debug output. * feat: make InstalledScriptsTab mobile-friendly with responsive layout - Created ScriptInstallationCard component for mobile view - Added responsive layout: cards on mobile (< md), table on desktop (>= md) - Made filters section mobile-friendly with stacked layout - Improved add script form with responsive button layout - Cards show all script details in a clean, touch-friendly format - Maintained all existing functionality (edit, update, delete) - Used proper Tailwind breakpoints for seamless responsive behavior The installed scripts tab now provides an optimal experience on both mobile and desktop devices. * fix: resolve React hooks dependency warnings in Terminal component - Added missing 'inWhiptailSession' dependency to useCallback - Fixed ref cleanup issue by storing terminalRef.current in variable - Added missing 'isMobile' dependency to useEffect - Improved nullish coalescing in ScriptInstallationCard (|| to ??) - All React hooks warnings resolved, build passes cleanly The Terminal component now follows React best practices for hooks dependencies.
This commit is contained in:
committed by
GitHub
parent
39a572a393
commit
d932f5a499
@@ -196,7 +196,7 @@ export function CategorySidebar({
|
||||
|
||||
return (
|
||||
<div className={`bg-card rounded-lg shadow-md border border-border transition-all duration-300 ${
|
||||
isCollapsed ? 'w-16' : 'w-80'
|
||||
isCollapsed ? 'w-16' : 'w-full lg:w-80'
|
||||
}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
@@ -292,7 +292,7 @@ export function CategorySidebar({
|
||||
|
||||
{/* Collapsed state - show only icons with counters and tooltips */}
|
||||
{isCollapsed && (
|
||||
<div className="p-2 flex flex-col space-y-2">
|
||||
<div className="p-2 flex flex-row lg:flex-col space-x-2 lg:space-x-0 lg:space-y-2 overflow-x-auto lg:overflow-x-visible">
|
||||
{/* "All Categories" option */}
|
||||
<div className="group relative">
|
||||
<button
|
||||
@@ -317,7 +317,7 @@ export function CategorySidebar({
|
||||
</button>
|
||||
|
||||
{/* Tooltip */}
|
||||
<div className="absolute left-full ml-2 top-1/2 transform -translate-y-1/2 bg-gray-900 text-white text-sm px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50">
|
||||
<div className="absolute left-full ml-2 top-1/2 transform -translate-y-1/2 bg-gray-900 text-white text-sm px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50 hidden lg:block">
|
||||
All Categories ({totalScripts})
|
||||
</div>
|
||||
</div>
|
||||
@@ -350,7 +350,7 @@ export function CategorySidebar({
|
||||
</button>
|
||||
|
||||
{/* Tooltip */}
|
||||
<div className="absolute left-full ml-2 top-1/2 transform -translate-y-1/2 bg-gray-900 text-white text-sm px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50">
|
||||
<div className="absolute left-full ml-2 top-1/2 transform -translate-y-1/2 bg-gray-900 text-white text-sm px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50 hidden lg:block">
|
||||
{category} ({count})
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -69,7 +69,7 @@ export function DiffViewer({ scriptSlug, filePath, isOpen, onClose }: DiffViewer
|
||||
className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center p-4 z-50"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] overflow-hidden border border-border">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] overflow-hidden border border-border mx-4 sm:mx-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<div>
|
||||
|
||||
@@ -320,9 +320,9 @@ export function DownloadedScriptsTab() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6">
|
||||
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
|
||||
{/* Category Sidebar */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex-shrink-0 order-2 lg:order-1">
|
||||
<CategorySidebar
|
||||
categories={categories}
|
||||
categoryCounts={categoryCounts}
|
||||
@@ -333,7 +333,7 @@ export function DownloadedScriptsTab() {
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 min-w-0" ref={gridRef}>
|
||||
<div className="flex-1 min-w-0 order-1 lg:order-2" ref={gridRef}>
|
||||
{/* Enhanced Filter Bar */}
|
||||
<FilterBar
|
||||
filters={filters}
|
||||
|
||||
@@ -61,8 +61,8 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-md w-full mx-4 border border-border">
|
||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||
<h2 className="text-xl font-bold text-foreground">Execution Mode</h2>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { Package, Monitor, Wrench, Server, FileText, Calendar } from "lucide-react";
|
||||
import { Package, Monitor, Wrench, Server, FileText, Calendar, RefreshCw, Filter } from "lucide-react";
|
||||
|
||||
export interface FilterState {
|
||||
searchQuery: string;
|
||||
@@ -35,6 +35,7 @@ export function FilterBar({
|
||||
updatableCount = 0,
|
||||
}: FilterBarProps) {
|
||||
const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false);
|
||||
const [isSortDropdownOpen, setIsSortDropdownOpen] = useState(false);
|
||||
|
||||
const updateFilters = (updates: Partial<FilterState>) => {
|
||||
onFiltersChange({ ...filters, ...updates });
|
||||
@@ -76,10 +77,10 @@ export function FilterBar({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 rounded-lg border border-border bg-card p-6 shadow-sm">
|
||||
<div className="mb-6 rounded-lg border border-border bg-card p-4 sm:p-6 shadow-sm">
|
||||
{/* Search Bar */}
|
||||
<div className="mb-4">
|
||||
<div className="relative max-w-md">
|
||||
<div className="relative max-w-md w-full">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<svg
|
||||
className="h-5 w-5 text-muted-foreground"
|
||||
@@ -128,7 +129,7 @@ export function FilterBar({
|
||||
</div>
|
||||
|
||||
{/* Filter Buttons */}
|
||||
<div className="mb-4 flex flex-wrap gap-3">
|
||||
<div className="mb-4 flex flex-col sm:flex-row flex-wrap gap-2 sm:gap-3">
|
||||
{/* Updateable Filter */}
|
||||
<Button
|
||||
onClick={() => {
|
||||
@@ -142,29 +143,31 @@ export function FilterBar({
|
||||
}}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className={`${
|
||||
filters.showUpdatable === null
|
||||
? "bg-muted text-muted-foreground hover:bg-accent"
|
||||
: filters.showUpdatable === true
|
||||
? "border border-green-500/20 bg-green-500/10 text-green-400"
|
||||
: "border border-destructive/20 bg-destructive/10 text-destructive"
|
||||
}`}
|
||||
className={`w-full sm:w-auto flex items-center justify-center space-x-2 ${
|
||||
filters.showUpdatable === null
|
||||
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
: filters.showUpdatable === true
|
||||
? "border border-green-500/20 bg-green-500/10 text-green-400"
|
||||
: "border border-destructive/20 bg-destructive/10 text-destructive"
|
||||
}`}
|
||||
>
|
||||
{getUpdatableButtonText()}
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
<span>{getUpdatableButtonText()}</span>
|
||||
</Button>
|
||||
|
||||
{/* Type Dropdown */}
|
||||
<div className="relative">
|
||||
<div className="relative w-full sm:w-auto">
|
||||
<Button
|
||||
onClick={() => setIsTypeDropdownOpen(!isTypeDropdownOpen)}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className={`flex items-center space-x-2 ${
|
||||
className={`w-full flex items-center justify-center space-x-2 ${
|
||||
filters.selectedTypes.length === 0
|
||||
? "bg-muted text-muted-foreground hover:bg-accent"
|
||||
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
: "border border-primary/20 bg-primary/10 text-primary"
|
||||
}`}
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
<span>{getTypeButtonText()}</span>
|
||||
<svg
|
||||
className={`h-4 w-4 transition-transform ${isTypeDropdownOpen ? "rotate-180" : ""}`}
|
||||
@@ -237,85 +240,122 @@ export function FilterBar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sort Options */}
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* Sort By Dropdown */}
|
||||
<div className="relative inline-flex items-center">
|
||||
<select
|
||||
value={filters.sortBy}
|
||||
onChange={(e) =>
|
||||
updateFilters({ sortBy: e.target.value as "name" | "created" })
|
||||
}
|
||||
className="rounded-lg border border-input bg-background pl-9 pr-3 py-2 text-sm text-foreground focus:ring-2 focus:ring-primary focus:outline-none appearance-none"
|
||||
>
|
||||
<option value="name">By Name</option>
|
||||
<option value="created">By Created Date</option>
|
||||
</select>
|
||||
<div className="absolute left-2 pointer-events-none">
|
||||
{filters.sortBy === "name" ? (
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sort Order Button */}
|
||||
{/* Sort By Dropdown */}
|
||||
<div className="relative w-full sm:w-auto">
|
||||
<Button
|
||||
onClick={() =>
|
||||
updateFilters({
|
||||
sortOrder: filters.sortOrder === "asc" ? "desc" : "asc",
|
||||
})
|
||||
}
|
||||
onClick={() => setIsSortDropdownOpen(!isSortDropdownOpen)}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex items-center space-x-1 bg-muted text-muted-foreground hover:bg-accent"
|
||||
className="w-full sm:w-auto flex items-center justify-center space-x-2 bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
{filters.sortOrder === "asc" ? (
|
||||
<>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 11l5-5m0 0l5 5m-5-5v12"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
{filters.sortBy === "created" ? "Oldest First" : "A-Z"}
|
||||
</span>
|
||||
</>
|
||||
{filters.sortBy === "name" ? (
|
||||
<FileText className="h-4 w-4" />
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 13l-5 5m0 0l-5-5m5 5V6"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
{filters.sortBy === "created" ? "Newest First" : "Z-A"}
|
||||
</span>
|
||||
</>
|
||||
<Calendar className="h-4 w-4" />
|
||||
)}
|
||||
<span>{filters.sortBy === "name" ? "By Name" : "By Created Date"}</span>
|
||||
<svg
|
||||
className={`h-4 w-4 transition-transform ${isSortDropdownOpen ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
|
||||
{isSortDropdownOpen && (
|
||||
<div className="absolute top-full left-0 z-10 mt-1 w-full sm:w-48 rounded-lg border border-border bg-card shadow-lg">
|
||||
<div className="p-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
updateFilters({ sortBy: "name" });
|
||||
setIsSortDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full flex items-center space-x-3 rounded-md px-3 py-2 text-left hover:bg-accent ${
|
||||
filters.sortBy === "name" ? "bg-primary/10 text-primary" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="text-sm">By Name</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
updateFilters({ sortBy: "created" });
|
||||
setIsSortDropdownOpen(false);
|
||||
}}
|
||||
className={`w-full flex items-center space-x-3 rounded-md px-3 py-2 text-left hover:bg-accent ${
|
||||
filters.sortBy === "created" ? "bg-primary/10 text-primary" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span className="text-sm">By Created Date</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sort Order Button */}
|
||||
<Button
|
||||
onClick={() =>
|
||||
updateFilters({
|
||||
sortOrder: filters.sortOrder === "asc" ? "desc" : "asc",
|
||||
})
|
||||
}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="w-full sm:w-auto flex items-center justify-center space-x-1 bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
{filters.sortOrder === "asc" ? (
|
||||
<>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M7 11l5-5m0 0l5 5m-5-5v12"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
{filters.sortBy === "created" ? "Oldest First" : "A-Z"}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M17 13l-5 5m0 0l-5-5m5 5V6"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
{filters.sortBy === "created" ? "Newest First" : "Z-A"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filter Summary and Clear All */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{filteredCount === totalScripts ? (
|
||||
<span>Showing all {totalScripts} scripts</span>
|
||||
@@ -336,7 +376,7 @@ export function FilterBar({
|
||||
onClick={clearAllFilters}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="flex items-center space-x-1 text-red-600 hover:bg-red-50 hover:text-red-800"
|
||||
className="flex items-center space-x-1 text-red-600 hover:bg-red-50 hover:text-red-800 w-full sm:w-auto justify-center sm:justify-start"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
@@ -356,11 +396,14 @@ export function FilterBar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Click outside to close dropdown */}
|
||||
{isTypeDropdownOpen && (
|
||||
{/* Click outside to close dropdowns */}
|
||||
{(isTypeDropdownOpen || isSortDropdownOpen) && (
|
||||
<div
|
||||
className="fixed inset-0 z-0"
|
||||
onClick={() => setIsTypeDropdownOpen(false)}
|
||||
onClick={() => {
|
||||
setIsTypeDropdownOpen(false);
|
||||
setIsSortDropdownOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { api } from '~/trpc/react';
|
||||
import { Terminal } from './Terminal';
|
||||
import { StatusBadge } from './Badge';
|
||||
import { Button } from './ui/button';
|
||||
import { ScriptInstallationCard } from './ScriptInstallationCard';
|
||||
|
||||
interface InstalledScript {
|
||||
id: number;
|
||||
@@ -263,9 +264,9 @@ export function InstalledScriptsTab() {
|
||||
|
||||
{/* Add Script Form */}
|
||||
{showAddForm && (
|
||||
<div className="mb-6 p-6 bg-card rounded-lg border border-border shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-6">Add Manual Script Entry</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="mb-6 p-4 sm:p-6 bg-card rounded-lg border border-border shadow-sm">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4 sm:mb-6">Add Manual Script Entry</h3>
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Script Name *
|
||||
@@ -308,11 +309,12 @@ export function InstalledScriptsTab() {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-3 mt-6">
|
||||
<div className="flex flex-col sm:flex-row justify-end gap-3 mt-4 sm:mt-6">
|
||||
<Button
|
||||
onClick={handleCancelAdd}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -321,6 +323,7 @@ export function InstalledScriptsTab() {
|
||||
disabled={createScriptMutation.isPending}
|
||||
variant="default"
|
||||
size="default"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{createScriptMutation.isPending ? 'Adding...' : 'Add Script'}
|
||||
</Button>
|
||||
@@ -329,8 +332,9 @@ export function InstalledScriptsTab() {
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex-1 min-w-64">
|
||||
<div className="space-y-4">
|
||||
{/* Search Input - Full Width on Mobile */}
|
||||
<div className="w-full">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search scripts, container IDs, or servers..."
|
||||
@@ -340,169 +344,195 @@ export function InstalledScriptsTab() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as 'all' | 'success' | 'failed' | 'in_progress')}
|
||||
className="px-3 py-2 border border-border rounded-md bg-card text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="success">Success</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
</select>
|
||||
{/* Filter Dropdowns - Responsive Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as 'all' | 'success' | 'failed' | 'in_progress')}
|
||||
className="w-full px-3 py-2 border border-border rounded-md bg-card text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="success">Success</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={serverFilter}
|
||||
onChange={(e) => setServerFilter(e.target.value)}
|
||||
className="px-3 py-2 border border-border rounded-md bg-card text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="all">All Servers</option>
|
||||
<option value="local">Local</option>
|
||||
{uniqueServers.map(server => (
|
||||
<option key={server} value={server}>{server}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={serverFilter}
|
||||
onChange={(e) => setServerFilter(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-border rounded-md bg-card text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
>
|
||||
<option value="all">All Servers</option>
|
||||
<option value="local">Local</option>
|
||||
{uniqueServers.map(server => (
|
||||
<option key={server} value={server}>{server}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scripts Table */}
|
||||
{/* Scripts Display - Mobile Cards / Desktop Table */}
|
||||
<div className="bg-card rounded-lg shadow overflow-hidden">
|
||||
{filteredScripts.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
{scripts.length === 0 ? 'No installed scripts found.' : 'No scripts match your filters.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Script Name
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Container ID
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Server
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Installation Date
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-card divide-y divide-gray-200">
|
||||
{filteredScripts.map((script) => (
|
||||
<tr key={script.id} className="hover:bg-accent">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{editingScriptId === script.id ? (
|
||||
<div className="space-y-2">
|
||||
<>
|
||||
{/* Mobile Card Layout */}
|
||||
<div className="block md:hidden p-4 space-y-4">
|
||||
{filteredScripts.map((script) => (
|
||||
<ScriptInstallationCard
|
||||
key={script.id}
|
||||
script={script}
|
||||
isEditing={editingScriptId === script.id}
|
||||
editFormData={editFormData}
|
||||
onInputChange={handleInputChange}
|
||||
onEdit={() => handleEditScript(script)}
|
||||
onSave={handleSaveEdit}
|
||||
onCancel={handleCancelEdit}
|
||||
onUpdate={() => handleUpdateScript(script)}
|
||||
onDelete={() => handleDeleteScript(Number(script.id))}
|
||||
isUpdating={updateScriptMutation.isPending}
|
||||
isDeleting={deleteScriptMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Desktop Table Layout */}
|
||||
<div className="hidden md:block overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Script Name
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Container ID
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Server
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Installation Date
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-card divide-y divide-gray-200">
|
||||
{filteredScripts.map((script) => (
|
||||
<tr key={script.id} className="hover:bg-accent">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{editingScriptId === script.id ? (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editFormData.script_name}
|
||||
onChange={(e) => handleInputChange('script_name', e.target.value)}
|
||||
className="w-full px-2 py-1 text-sm border border-border rounded bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
placeholder="Script name"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">{script.script_path}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">{script.script_name}</div>
|
||||
<div className="text-sm text-muted-foreground">{script.script_path}</div>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{editingScriptId === script.id ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editFormData.script_name}
|
||||
onChange={(e) => handleInputChange('script_name', e.target.value)}
|
||||
className="w-full px-2 py-1 text-sm border border-border rounded bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
placeholder="Script name"
|
||||
value={editFormData.container_id}
|
||||
onChange={(e) => handleInputChange('container_id', e.target.value)}
|
||||
className="w-full px-2 py-1 text-sm font-mono border border-border rounded bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
placeholder="Container ID"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">{script.script_path}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground">{script.script_name}</div>
|
||||
<div className="text-sm text-muted-foreground">{script.script_path}</div>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{editingScriptId === script.id ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editFormData.container_id}
|
||||
onChange={(e) => handleInputChange('container_id', e.target.value)}
|
||||
className="w-full px-2 py-1 text-sm font-mono border border-border rounded bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
placeholder="Container ID"
|
||||
/>
|
||||
) : (
|
||||
script.container_id ? (
|
||||
<span className="text-sm font-mono text-foreground">{String(script.container_id)}</span>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
)
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{script.server_name ?? 'Local'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<StatusBadge status={script.status}>
|
||||
{script.status.replace('_', ' ').toUpperCase()}
|
||||
</StatusBadge>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||
{formatDate(String(script.installation_date))}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex space-x-2">
|
||||
{editingScriptId === script.id ? (
|
||||
<>
|
||||
<Button
|
||||
onClick={handleSaveEdit}
|
||||
disabled={updateScriptMutation.isPending}
|
||||
variant="default"
|
||||
size="sm"
|
||||
>
|
||||
{updateScriptMutation.isPending ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCancelEdit}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => handleEditScript(script)}
|
||||
variant="default"
|
||||
size="sm"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
{script.container_id && (
|
||||
script.container_id ? (
|
||||
<span className="text-sm font-mono text-foreground">{String(script.container_id)}</span>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
)
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{script.server_name ?? 'Local'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<StatusBadge status={script.status}>
|
||||
{script.status.replace('_', ' ').toUpperCase()}
|
||||
</StatusBadge>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||
{formatDate(String(script.installation_date))}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex space-x-2">
|
||||
{editingScriptId === script.id ? (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => handleUpdateScript(script)}
|
||||
variant="link"
|
||||
onClick={handleSaveEdit}
|
||||
disabled={updateScriptMutation.isPending}
|
||||
variant="default"
|
||||
size="sm"
|
||||
>
|
||||
Update
|
||||
{updateScriptMutation.isPending ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => handleDeleteScript(Number(script.id))}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={deleteScriptMutation.isPending}
|
||||
>
|
||||
{deleteScriptMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCancelEdit}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => handleEditScript(script)}
|
||||
variant="default"
|
||||
size="sm"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
{script.container_id && (
|
||||
<Button
|
||||
onClick={() => handleUpdateScript(script)}
|
||||
variant="link"
|
||||
size="sm"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => handleDeleteScript(Number(script.id))}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={deleteScriptMutation.isPending}
|
||||
>
|
||||
{deleteScriptMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -136,38 +136,63 @@ export function ScriptDetailModal({
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm bg-black/50"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[95vh] min-h-[80vh] overflow-y-auto border border-border">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[95vh] min-h-[80vh] overflow-y-auto border border-border mx-2 sm:mx-4 lg:mx-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border p-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center justify-between border-b border-border p-4 sm:p-6">
|
||||
<div className="flex items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
|
||||
{script.logo && !imageError ? (
|
||||
<Image
|
||||
src={script.logo}
|
||||
alt={`${script.name} logo`}
|
||||
width={64}
|
||||
height={64}
|
||||
className="h-16 w-16 rounded-lg object-contain"
|
||||
className="h-12 w-12 sm:h-16 sm:w-16 rounded-lg object-contain flex-shrink-0"
|
||||
onError={handleImageError}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-muted">
|
||||
<span className="text-2xl font-semibold text-muted-foreground">
|
||||
<div className="flex h-12 w-12 sm:h-16 sm:w-16 items-center justify-center rounded-lg bg-muted flex-shrink-0">
|
||||
<span className="text-lg sm:text-2xl font-semibold text-muted-foreground">
|
||||
{script.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-foreground">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-foreground truncate">
|
||||
{script.name}
|
||||
</h2>
|
||||
<div className="mt-1 flex items-center space-x-2">
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1 sm:gap-2">
|
||||
<TypeBadge type={script.type} />
|
||||
{script.updateable && <UpdateableBadge />}
|
||||
{script.privileged && <PrivilegedBadge />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
|
||||
{/* Close Button */}
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground flex-shrink-0 ml-4"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5 sm:h-6 sm:w-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center space-y-2 sm:space-y-0 sm:space-x-2 p-4 sm:p-6 border-b border-border">
|
||||
{/* Install Button - only show if script files exist */}
|
||||
{scriptFilesData?.success &&
|
||||
scriptFilesData.ctExists &&
|
||||
@@ -176,7 +201,7 @@ export function ScriptDetailModal({
|
||||
onClick={handleInstallScript}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex items-center space-x-2"
|
||||
className="w-full sm:w-auto flex items-center justify-center space-x-2"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
@@ -202,7 +227,7 @@ export function ScriptDetailModal({
|
||||
onClick={handleViewScript}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex items-center space-x-2 "
|
||||
className="w-full sm:w-auto flex items-center justify-center space-x-2"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
@@ -335,39 +360,18 @@ export function ScriptDetailModal({
|
||||
);
|
||||
}
|
||||
})()}
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Load Message */}
|
||||
{loadMessage && (
|
||||
<div className="mx-6 mb-4 rounded-lg bg-primary/10 p-3 text-sm text-primary">
|
||||
<div className="mx-4 sm:mx-6 mb-4 rounded-lg bg-primary/10 p-3 text-sm text-primary">
|
||||
{loadMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Script Files Status */}
|
||||
{(scriptFilesLoading || comparisonLoading) && (
|
||||
<div className="mx-6 mb-4 rounded-lg bg-primary/10 p-3 text-sm text-primary">
|
||||
<div className="mx-4 sm:mx-6 mb-4 rounded-lg bg-primary/10 p-3 text-sm text-primary">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-blue-600"></div>
|
||||
<span>Loading script status...</span>
|
||||
@@ -392,8 +396,8 @@ export function ScriptDetailModal({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-6 mb-4 rounded-lg bg-muted p-3 text-sm text-muted-foreground">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="mx-4 sm:mx-6 mb-4 rounded-lg bg-muted p-3 text-sm text-muted-foreground">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center space-y-2 sm:space-y-0 sm:space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className={`h-2 w-2 rounded-full ${scriptFilesData.ctExists ? "bg-green-500" : "bg-muted"}`}
|
||||
@@ -433,7 +437,7 @@ export function ScriptDetailModal({
|
||||
)}
|
||||
</div>
|
||||
{scriptFilesData.files.length > 0 && (
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
<div className="mt-2 text-xs text-muted-foreground break-words">
|
||||
Files: {scriptFilesData.files.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
@@ -442,21 +446,21 @@ export function ScriptDetailModal({
|
||||
})()}
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
|
||||
{/* Description */}
|
||||
<div>
|
||||
<h3 className="mb-2 text-lg font-semibold text-foreground">
|
||||
<h3 className="mb-2 text-base sm:text-lg font-semibold text-foreground">
|
||||
Description
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
<p className="text-sm sm:text-base text-muted-foreground">
|
||||
{script.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Basic Information */}
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<div className="grid grid-cols-1 gap-4 sm:gap-6 lg:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="mb-3 text-lg font-semibold text-foreground">
|
||||
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
|
||||
Basic Information
|
||||
</h3>
|
||||
<dl className="space-y-2">
|
||||
@@ -508,7 +512,7 @@ export function ScriptDetailModal({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="mb-3 text-lg font-semibold text-foreground">
|
||||
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
|
||||
Links
|
||||
</h3>
|
||||
<dl className="space-y-2">
|
||||
@@ -555,24 +559,24 @@ export function ScriptDetailModal({
|
||||
script.type !== "pve" &&
|
||||
script.type !== "addon" && (
|
||||
<div>
|
||||
<h3 className="mb-3 text-lg font-semibold text-foreground">
|
||||
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
|
||||
Install Methods
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{script.install_methods.map((method, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="rounded-lg border border-border bg-card p-4"
|
||||
className="rounded-lg border border-border bg-card p-3 sm:p-4"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h4 className="font-medium text-foreground capitalize">
|
||||
<div className="mb-3 flex flex-col sm:flex-row sm:items-center justify-between space-y-1 sm:space-y-0">
|
||||
<h4 className="text-sm sm:text-base font-medium text-foreground capitalize">
|
||||
{method.type}
|
||||
</h4>
|
||||
<span className="font-mono text-sm text-muted-foreground">
|
||||
<span className="font-mono text-xs sm:text-sm text-muted-foreground break-all">
|
||||
{method.script}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm md:grid-cols-4">
|
||||
<div className="grid grid-cols-2 gap-2 sm:gap-4 text-xs sm:text-sm lg:grid-cols-4">
|
||||
<div>
|
||||
<dt className="font-medium text-muted-foreground">
|
||||
CPU
|
||||
@@ -616,7 +620,7 @@ export function ScriptDetailModal({
|
||||
{(script.default_credentials.username ??
|
||||
script.default_credentials.password) && (
|
||||
<div>
|
||||
<h3 className="mb-3 text-lg font-semibold text-foreground">
|
||||
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
|
||||
Default Credentials
|
||||
</h3>
|
||||
<dl className="space-y-2">
|
||||
|
||||
175
src/app/_components/ScriptInstallationCard.tsx
Normal file
175
src/app/_components/ScriptInstallationCard.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from './ui/button';
|
||||
import { StatusBadge } from './Badge';
|
||||
|
||||
interface InstalledScript {
|
||||
id: number;
|
||||
script_name: string;
|
||||
script_path: string;
|
||||
container_id: string | null;
|
||||
server_id: number | null;
|
||||
server_name: string | null;
|
||||
server_ip: string | null;
|
||||
server_user: string | null;
|
||||
server_password: string | null;
|
||||
installation_date: string;
|
||||
status: 'in_progress' | 'success' | 'failed';
|
||||
output_log: string | null;
|
||||
}
|
||||
|
||||
interface ScriptInstallationCardProps {
|
||||
script: InstalledScript;
|
||||
isEditing: boolean;
|
||||
editFormData: { script_name: string; container_id: string };
|
||||
onInputChange: (field: 'script_name' | 'container_id', value: string) => void;
|
||||
onEdit: () => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
onUpdate: () => void;
|
||||
onDelete: () => void;
|
||||
isUpdating: boolean;
|
||||
isDeleting: boolean;
|
||||
}
|
||||
|
||||
export function ScriptInstallationCard({
|
||||
script,
|
||||
isEditing,
|
||||
editFormData,
|
||||
onInputChange,
|
||||
onEdit,
|
||||
onSave,
|
||||
onCancel,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
isUpdating,
|
||||
isDeleting
|
||||
}: ScriptInstallationCardProps) {
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-lg p-4 shadow-sm hover:shadow-md transition-shadow">
|
||||
{/* Header with Script Name and Status */}
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
{isEditing ? (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editFormData.script_name}
|
||||
onChange={(e) => onInputChange('script_name', e.target.value)}
|
||||
className="w-full px-2 py-1 text-sm border border-border rounded bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
placeholder="Script name"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">{script.script_path}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="text-sm font-medium text-foreground truncate">{script.script_name}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">{script.script_path}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-2 flex-shrink-0">
|
||||
<StatusBadge status={script.status}>
|
||||
{script.status.replace('_', ' ').toUpperCase()}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details Grid */}
|
||||
<div className="grid grid-cols-1 gap-3 mb-4">
|
||||
{/* Container ID */}
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">Container ID</div>
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editFormData.container_id}
|
||||
onChange={(e) => onInputChange('container_id', e.target.value)}
|
||||
className="w-full px-2 py-1 text-sm font-mono border border-border rounded bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
placeholder="Container ID"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm font-mono text-foreground break-all">
|
||||
{script.container_id ?? '-'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Server */}
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">Server</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{script.server_name ?? 'Local'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Installation Date */}
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1">Installation Date</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{formatDate(String(script.installation_date))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button
|
||||
onClick={onSave}
|
||||
disabled={isUpdating}
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1 min-w-0"
|
||||
>
|
||||
{isUpdating ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 min-w-0"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
onClick={onEdit}
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1 min-w-0"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
{script.container_id && (
|
||||
<Button
|
||||
onClick={onUpdate}
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="flex-1 min-w-0"
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={onDelete}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={isDeleting}
|
||||
className="flex-1 min-w-0"
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -316,9 +316,9 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-6">
|
||||
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
|
||||
{/* Category Sidebar */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex-shrink-0 order-2 lg:order-1">
|
||||
<CategorySidebar
|
||||
categories={categories}
|
||||
categoryCounts={categoryCounts}
|
||||
@@ -329,7 +329,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 min-w-0" ref={gridRef}>
|
||||
<div className="flex-1 min-w-0 order-1 lg:order-2" ref={gridRef}>
|
||||
{/* Enhanced Filter Bar */}
|
||||
<FilterBar
|
||||
filters={filters}
|
||||
|
||||
@@ -74,7 +74,7 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||
Server Name *
|
||||
@@ -144,13 +144,14 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<div className="flex flex-col sm:flex-row justify-end space-y-2 sm:space-y-0 sm:space-x-3 pt-4">
|
||||
{isEditing && onCancel && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="w-full sm:w-auto order-2 sm:order-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
@@ -159,6 +160,7 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
type="submit"
|
||||
variant="default"
|
||||
size="default"
|
||||
className="w-full sm:w-auto order-1 sm:order-2"
|
||||
>
|
||||
{isEditing ? 'Update Server' : 'Add Server'}
|
||||
</Button>
|
||||
|
||||
@@ -102,30 +102,30 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between space-y-4 sm:space-y-0">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start sm:items-center space-x-3">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div className="w-8 h-8 sm:w-10 sm:h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<svg className="w-4 h-4 sm:w-6 sm:h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-medium text-foreground truncate">{server.name}</h3>
|
||||
<div className="mt-1 flex items-center space-x-4 text-sm text-muted-foreground">
|
||||
<h3 className="text-base sm:text-lg font-medium text-foreground truncate">{server.name}</h3>
|
||||
<div className="mt-1 flex flex-col sm:flex-row sm:items-center space-y-1 sm:space-y-0 sm:space-x-4 text-sm text-muted-foreground">
|
||||
<span className="flex items-center">
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg className="w-4 h-4 mr-1 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9v-9m0-9v9" />
|
||||
</svg>
|
||||
{server.ip}
|
||||
<span className="truncate">{server.ip}</span>
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg className="w-4 h-4 mr-1 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
{server.user}
|
||||
<span className="truncate">{server.user}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
@@ -162,51 +162,58 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex flex-col sm:flex-row items-stretch sm:items-center space-y-2 sm:space-y-0 sm:space-x-2">
|
||||
<Button
|
||||
onClick={() => handleTestConnection(server)}
|
||||
disabled={testingConnections.has(server.id)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-green-500/20 text-green-400 bg-green-500/10 hover:bg-green-500/20"
|
||||
className="w-full sm:w-auto border-green-500/20 text-green-400 bg-green-500/10 hover:bg-green-500/20"
|
||||
>
|
||||
{testingConnections.has(server.id) ? (
|
||||
<>
|
||||
<svg className="w-4 h-4 mr-1 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Testing...
|
||||
<span className="hidden sm:inline">Testing...</span>
|
||||
<span className="sm:hidden">Test...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Test Connection
|
||||
<span className="hidden sm:inline">Test Connection</span>
|
||||
<span className="sm:hidden">Test</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleEdit(server)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDelete(server.id)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-destructive/20 text-destructive bg-destructive/10 hover:bg-destructive/20"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
Delete
|
||||
</Button>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
onClick={() => handleEdit(server)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 sm:flex-none"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Edit</span>
|
||||
<span className="sm:hidden">✏️</span>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDelete(server.id)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 sm:flex-none border-destructive/20 text-destructive bg-destructive/10 hover:bg-destructive/20"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
<span className="hidden sm:inline">Delete</span>
|
||||
<span className="sm:hidden">🗑️</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -99,18 +99,18 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-hidden">
|
||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-2 sm:p-4">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-4xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||
<h2 className="text-2xl font-bold text-card-foreground">Settings</h2>
|
||||
<div className="flex items-center justify-between p-4 sm:p-6 border-b border-border">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-card-foreground">Settings</h2>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<svg className="w-5 h-5 sm:w-6 sm:h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</Button>
|
||||
@@ -118,12 +118,12 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex space-x-8 px-6">
|
||||
<nav className="flex flex-col sm:flex-row space-y-1 sm:space-y-0 sm:space-x-8 px-4 sm:px-6">
|
||||
<Button
|
||||
onClick={() => setActiveTab('servers')}
|
||||
variant="ghost"
|
||||
size="null"
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
className={`py-3 sm:py-4 px-1 border-b-2 font-medium text-sm w-full sm:w-auto ${
|
||||
activeTab === 'servers'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||
@@ -135,7 +135,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
onClick={() => setActiveTab('general')}
|
||||
variant="ghost"
|
||||
size="null"
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm ${
|
||||
className={`py-3 sm:py-4 px-1 border-b-2 font-medium text-sm w-full sm:w-auto ${
|
||||
activeTab === 'general'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||
@@ -147,32 +147,32 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 overflow-y-auto max-h-[calc(90vh-200px)]">
|
||||
<div className="p-4 sm:p-6 overflow-y-auto max-h-[calc(95vh-180px)] sm:max-h-[calc(90vh-200px)]">
|
||||
{error && (
|
||||
<div className="mb-4 p-4 bg-destructive/10 border border-destructive rounded-md">
|
||||
<div className="mb-4 p-3 sm:p-4 bg-destructive/10 border border-destructive rounded-md">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<svg className="h-4 w-4 sm:h-5 sm:w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">Error</h3>
|
||||
<div className="mt-2 text-sm text-red-700">{error}</div>
|
||||
<div className="ml-2 sm:ml-3 min-w-0 flex-1">
|
||||
<h3 className="text-xs sm:text-sm font-medium text-red-800">Error</h3>
|
||||
<div className="mt-1 sm:mt-2 text-xs sm:text-sm text-red-700 break-words">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'servers' && (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-foreground mb-4">Server Configurations</h3>
|
||||
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">Server Configurations</h3>
|
||||
<ServerForm onSubmit={handleCreateServer} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-foreground mb-4">Saved Servers</h3>
|
||||
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">Saved Servers</h3>
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
@@ -191,8 +191,8 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
|
||||
{activeTab === 'general' && (
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-foreground mb-4">General Settings</h3>
|
||||
<p className="text-muted-foreground">General settings will be available in a future update.</p>
|
||||
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">General Settings</h3>
|
||||
<p className="text-sm sm:text-base text-muted-foreground">General settings will be available in a future update.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { Button } from './ui/button';
|
||||
import { Play, Square, Trash2, X } from 'lucide-react';
|
||||
import { Play, Square, Trash2, X, Send, Keyboard, ChevronUp, ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
interface TerminalProps {
|
||||
scriptPath: string;
|
||||
@@ -24,6 +24,11 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
const [mobileInput, setMobileInput] = useState('');
|
||||
const [showMobileInput, setShowMobileInput] = useState(false);
|
||||
const [lastInputSent, setLastInputSent] = useState<string | null>(null);
|
||||
const [inWhiptailSession, setInWhiptailSession] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const xtermRef = useRef<any>(null);
|
||||
const fitAddonRef = useRef<any>(null);
|
||||
@@ -47,7 +52,37 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
break;
|
||||
case 'output':
|
||||
// Write directly to terminal - xterm.js handles ANSI codes natively
|
||||
xtermRef.current.write(message.data);
|
||||
// Detect whiptail sessions and clear immediately
|
||||
if (message.data.includes('whiptail') || message.data.includes('dialog') || message.data.includes('Choose an option')) {
|
||||
setInWhiptailSession(true);
|
||||
// Clear terminal immediately when whiptail starts
|
||||
xtermRef.current.clear();
|
||||
xtermRef.current.write('\x1b[2J\x1b[H');
|
||||
}
|
||||
|
||||
// Check for screen clearing sequences and handle them properly
|
||||
if (message.data.includes('\x1b[2J') || message.data.includes('\x1b[H\x1b[2J')) {
|
||||
// This is a clear screen sequence, ensure it's processed correctly
|
||||
xtermRef.current.write(message.data);
|
||||
} else if (message.data.includes('\x1b[') && message.data.includes('H')) {
|
||||
// This is a cursor positioning sequence, often implies a redraw of the entire screen
|
||||
if (inWhiptailSession) {
|
||||
// In whiptail session, completely reset the terminal
|
||||
// Completely clear everything
|
||||
xtermRef.current.clear();
|
||||
xtermRef.current.write('\x1b[2J\x1b[H\x1b[3J\x1b[2J');
|
||||
// Reset the terminal state
|
||||
xtermRef.current.reset();
|
||||
// Write the new content after reset
|
||||
setTimeout(() => {
|
||||
xtermRef.current.write(message.data);
|
||||
}, 100);
|
||||
} else {
|
||||
xtermRef.current.write(message.data);
|
||||
}
|
||||
} else {
|
||||
xtermRef.current.write(message.data);
|
||||
}
|
||||
break;
|
||||
case 'error':
|
||||
// Check if this looks like ANSI terminal output (contains escape codes)
|
||||
@@ -66,6 +101,9 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
}
|
||||
break;
|
||||
case 'end':
|
||||
// Reset whiptail session
|
||||
setInWhiptailSession(false);
|
||||
|
||||
// Check if this is an LXC creation script
|
||||
const isLxcCreation = scriptPath.includes('ct/') ||
|
||||
scriptPath.includes('create_lxc') ||
|
||||
@@ -86,33 +124,40 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
setIsRunning(false);
|
||||
break;
|
||||
}
|
||||
}, [scriptPath, containerId, scriptName]);
|
||||
}, [scriptPath, containerId, scriptName, inWhiptailSession]);
|
||||
|
||||
// Ensure we're on the client side
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
// Detect mobile on mount
|
||||
setIsMobile(window.innerWidth < 768);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Only initialize on client side
|
||||
if (!isClient || !terminalRef.current || xtermRef.current) return;
|
||||
|
||||
// Store ref value to avoid stale closure
|
||||
const terminalElement = terminalRef.current;
|
||||
|
||||
// Use setTimeout to ensure DOM is fully ready
|
||||
const initTerminal = async () => {
|
||||
if (!terminalRef.current || xtermRef.current) return;
|
||||
if (!terminalElement || xtermRef.current) return;
|
||||
|
||||
// Dynamically import xterm modules to avoid SSR issues
|
||||
const { Terminal: XTerm } = await import('@xterm/xterm');
|
||||
const { FitAddon } = await import('@xterm/addon-fit');
|
||||
const { WebLinksAddon } = await import('@xterm/addon-web-links');
|
||||
|
||||
// Use the mobile state
|
||||
|
||||
const terminal = new XTerm({
|
||||
theme: {
|
||||
background: '#000000',
|
||||
foreground: '#00ff00',
|
||||
cursor: '#00ff00',
|
||||
},
|
||||
fontSize: 14,
|
||||
fontSize: isMobile ? 7 : 14,
|
||||
fontFamily: 'JetBrains Mono, Fira Code, Cascadia Code, Monaco, Menlo, Ubuntu Mono, monospace',
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'block',
|
||||
@@ -124,6 +169,12 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
macOptionIsMeta: false,
|
||||
rightClickSelectsWord: false,
|
||||
wordSeparator: ' ()[]{}\'"`<>|',
|
||||
// Better ANSI handling
|
||||
allowProposedApi: true,
|
||||
// Force proper terminal behavior for interactive applications
|
||||
// Use smaller dimensions on mobile but ensure proper fit
|
||||
cols: isMobile ? 45 : 80,
|
||||
rows: isMobile ? 18 : 24,
|
||||
});
|
||||
|
||||
// Add addons
|
||||
@@ -131,15 +182,41 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.loadAddon(webLinksAddon);
|
||||
|
||||
// Enable better ANSI handling
|
||||
terminal.options.allowProposedApi = true;
|
||||
|
||||
// Open terminal
|
||||
terminal.open(terminalRef.current);
|
||||
terminal.open(terminalElement);
|
||||
|
||||
// Fit after a small delay to ensure proper sizing
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
// Force fit multiple times for mobile to ensure proper sizing
|
||||
if (isMobile) {
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
}, 200);
|
||||
}, 300);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// Add resize listener for mobile responsiveness
|
||||
const handleResize = () => {
|
||||
if (fitAddonRef.current) {
|
||||
setTimeout(() => {
|
||||
fitAddonRef.current.fit();
|
||||
}, 50);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
// Store the handler for cleanup
|
||||
(terminalElement as any).resizeHandler = handleResize;
|
||||
|
||||
// Store references
|
||||
xtermRef.current = terminal;
|
||||
fitAddonRef.current = fitAddon;
|
||||
@@ -147,25 +224,16 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
// Handle terminal input
|
||||
terminal.onData((data) => {
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({
|
||||
const message = {
|
||||
action: 'input',
|
||||
executionId,
|
||||
input: data
|
||||
}));
|
||||
};
|
||||
wsRef.current.send(JSON.stringify(message));
|
||||
}
|
||||
});
|
||||
|
||||
// Handle terminal resize
|
||||
const handleResize = () => {
|
||||
if (fitAddonRef.current) {
|
||||
fitAddonRef.current.fit();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
terminal.dispose();
|
||||
};
|
||||
};
|
||||
@@ -177,13 +245,16 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
if (terminalElement && (terminalElement as any).resizeHandler) {
|
||||
window.removeEventListener('resize', (terminalElement as any).resizeHandler as (this: Window, ev: UIEvent) => any);
|
||||
}
|
||||
if (xtermRef.current) {
|
||||
xtermRef.current.dispose();
|
||||
xtermRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [executionId, isClient]);
|
||||
}, [executionId, isClient, inWhiptailSession, isMobile]);
|
||||
|
||||
useEffect(() => {
|
||||
// Prevent multiple connections in React Strict Mode
|
||||
@@ -229,6 +300,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data as string) as TerminalMessage;
|
||||
console.log('WebSocket message received:', message);
|
||||
handleMessage(message);
|
||||
} catch (error) {
|
||||
console.error('Error parsing WebSocket message:', error);
|
||||
@@ -260,7 +332,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
wsRef.current.close();
|
||||
}
|
||||
};
|
||||
}, [scriptPath, executionId, mode, server, isUpdate, containerId, handleMessage]);
|
||||
}, [scriptPath, executionId, mode, server, isUpdate, containerId, handleMessage, isMobile]);
|
||||
|
||||
const startScript = () => {
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
@@ -291,6 +363,30 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
}
|
||||
};
|
||||
|
||||
const sendInput = (input: string) => {
|
||||
setLastInputSent(input);
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||
const message = {
|
||||
action: 'input',
|
||||
executionId,
|
||||
input: input
|
||||
};
|
||||
wsRef.current.send(JSON.stringify(message));
|
||||
// Clear the feedback after 2 seconds
|
||||
setTimeout(() => setLastInputSent(null), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMobileInput = (input: string) => {
|
||||
sendInput(input);
|
||||
setMobileInput('');
|
||||
};
|
||||
|
||||
|
||||
const handleEnterKey = () => {
|
||||
sendInput('\r');
|
||||
};
|
||||
|
||||
// Don't render on server side
|
||||
if (!isClient) {
|
||||
return (
|
||||
@@ -317,21 +413,21 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
return (
|
||||
<div className="bg-card rounded-lg border border-border overflow-hidden">
|
||||
{/* Terminal Header */}
|
||||
<div className="bg-muted px-4 py-2 flex items-center justify-between border-b border-border">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex space-x-1">
|
||||
<div className="w-3 h-3 bg-red-500 rounded-full"></div>
|
||||
<div className="w-3 h-3 bg-yellow-500 rounded-full"></div>
|
||||
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
|
||||
<div className="bg-muted px-2 sm:px-4 py-2 flex items-center justify-between border-b border-border">
|
||||
<div className="flex items-center space-x-2 min-w-0 flex-1">
|
||||
<div className="flex space-x-1 flex-shrink-0">
|
||||
<div className="w-2 h-2 sm:w-3 sm:h-3 bg-red-500 rounded-full"></div>
|
||||
<div className="w-2 h-2 sm:w-3 sm:h-3 bg-yellow-500 rounded-full"></div>
|
||||
<div className="w-2 h-2 sm:w-3 sm:h-3 bg-green-500 rounded-full"></div>
|
||||
</div>
|
||||
<span className="text-foreground font-mono text-sm ml-2">
|
||||
<span className="text-foreground font-mono text-xs sm:text-sm ml-1 sm:ml-2 truncate">
|
||||
{scriptName} {mode === 'ssh' && server && `(SSH: ${server.name})`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center space-x-1 sm:space-x-2 flex-shrink-0">
|
||||
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`}></div>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
<span className="text-muted-foreground text-xs hidden sm:inline">
|
||||
{isConnected ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -340,22 +436,164 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
{/* Terminal Output */}
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className="h-[32rem] w-full max-w-4xl mx-auto"
|
||||
style={{ minHeight: '512px' }}
|
||||
className={`h-[16rem] sm:h-[24rem] lg:h-[32rem] w-full max-w-4xl mx-auto ${isMobile ? 'mobile-terminal' : ''}`}
|
||||
style={{
|
||||
minHeight: '256px'
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Mobile Input Controls - Only show on mobile */}
|
||||
<div className="block sm:hidden bg-muted/50 px-2 py-3 border-t border-border">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">Mobile Input</span>
|
||||
{lastInputSent && (
|
||||
<span className="text-xs text-green-500 bg-green-500/10 px-2 py-1 rounded">
|
||||
Sent: {lastInputSent === '\r' ? 'Enter' :
|
||||
lastInputSent === ' ' ? 'Space' :
|
||||
lastInputSent === '\b' ? 'Backspace' :
|
||||
lastInputSent === '\x1b[A' ? 'Up' :
|
||||
lastInputSent === '\x1b[B' ? 'Down' :
|
||||
lastInputSent === '\x1b[C' ? 'Right' :
|
||||
lastInputSent === '\x1b[D' ? 'Left' :
|
||||
lastInputSent}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setShowMobileInput(!showMobileInput)}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
<Keyboard className="h-4 w-4 mr-1" />
|
||||
{showMobileInput ? 'Hide' : 'Show'} Input
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showMobileInput && (
|
||||
<div className="space-y-3">
|
||||
{/* Navigation Buttons */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
onClick={() => sendInput('\x1b[A')}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-sm flex items-center justify-center gap-2"
|
||||
disabled={!isConnected}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
Up
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => sendInput('\x1b[B')}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-sm flex items-center justify-center gap-2"
|
||||
disabled={!isConnected}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
Down
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Left/Right Navigation Buttons */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
onClick={() => sendInput('\x1b[D')}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-sm flex items-center justify-center gap-2"
|
||||
disabled={!isConnected}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Left
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => sendInput('\x1b[C')}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-sm flex items-center justify-center gap-2"
|
||||
disabled={!isConnected}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
Right
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Button
|
||||
onClick={handleEnterKey}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-sm"
|
||||
disabled={!isConnected}
|
||||
>
|
||||
Enter
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => sendInput(' ')}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-sm"
|
||||
disabled={!isConnected}
|
||||
>
|
||||
Space
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => sendInput('\b')}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-sm"
|
||||
disabled={!isConnected}
|
||||
>
|
||||
⌫ Backspace
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Custom Input */}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={mobileInput}
|
||||
onChange={(e) => setMobileInput(e.target.value)}
|
||||
placeholder="Type command..."
|
||||
className="flex-1 px-3 py-2 text-sm border border-border rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleMobileInput(mobileInput);
|
||||
}
|
||||
}}
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => handleMobileInput(mobileInput)}
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={!isConnected || !mobileInput.trim()}
|
||||
className="px-3"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Terminal Controls */}
|
||||
<div className="bg-muted px-4 py-2 flex items-center justify-between border-t border-border">
|
||||
<div className="flex space-x-2">
|
||||
<div className="bg-muted px-2 sm:px-4 py-2 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-2 border-t border-border">
|
||||
<div className="flex flex-wrap gap-1 sm:gap-2">
|
||||
<Button
|
||||
onClick={startScript}
|
||||
disabled={!isConnected || isRunning}
|
||||
variant="default"
|
||||
size="sm"
|
||||
className={isConnected && !isRunning ? 'bg-green-600 hover:bg-green-700' : 'bg-muted text-muted-foreground cursor-not-allowed'}
|
||||
className={`text-xs sm:text-sm ${isConnected && !isRunning ? 'bg-green-600 hover:bg-green-700' : 'bg-muted text-muted-foreground cursor-not-allowed'}`}
|
||||
>
|
||||
<Play className="h-4 w-4 mr-1" />
|
||||
Start
|
||||
<Play className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
|
||||
<span className="hidden sm:inline">Start</span>
|
||||
<span className="sm:hidden">▶</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -363,20 +601,22 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
disabled={!isRunning}
|
||||
variant="default"
|
||||
size="sm"
|
||||
className={isRunning ? 'bg-red-600 hover:bg-red-700' : 'bg-muted text-muted-foreground cursor-not-allowed'}
|
||||
className={`text-xs sm:text-sm ${isRunning ? 'bg-red-600 hover:bg-red-700' : 'bg-muted text-muted-foreground cursor-not-allowed'}`}
|
||||
>
|
||||
<Square className="h-4 w-4 mr-1" />
|
||||
Stop
|
||||
<Square className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
|
||||
<span className="hidden sm:inline">Stop</span>
|
||||
<span className="sm:hidden">⏹</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={clearOutput}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="bg-secondary text-secondary-foreground hover:bg-secondary/80"
|
||||
className="text-xs sm:text-sm bg-secondary text-secondary-foreground hover:bg-secondary/80"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
Clear
|
||||
<Trash2 className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
|
||||
<span className="hidden sm:inline">Clear</span>
|
||||
<span className="sm:hidden">🗑</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -384,9 +624,9 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
onClick={onClose}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="bg-gray-600 text-white hover:bg-gray-700"
|
||||
className="text-xs sm:text-sm bg-gray-600 text-white hover:bg-gray-700 w-full sm:w-auto"
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
<X className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -103,7 +103,7 @@ export function TextViewer({ scriptName, isOpen, onClose }: TextViewerProps) {
|
||||
className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center p-4 z-50"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] flex flex-col border border-border">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] flex flex-col border border-border mx-4 sm:mx-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||
<div className="flex items-center space-x-4">
|
||||
|
||||
@@ -229,18 +229,18 @@ export function VersionDisplay() {
|
||||
{/* Loading overlay */}
|
||||
{isUpdating && <LoadingOverlay isNetworkError={isNetworkError} logs={updateLogs} />}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={isUpToDate ? "default" : "secondary"}>
|
||||
<div className="flex flex-col sm:flex-row items-center gap-2 sm:gap-2">
|
||||
<Badge variant={isUpToDate ? "default" : "secondary"} className="text-xs">
|
||||
v{currentVersion}
|
||||
</Badge>
|
||||
|
||||
{updateAvailable && releaseInfo && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-col sm:flex-row items-center gap-2 sm:gap-3">
|
||||
<div className="relative group">
|
||||
<Badge variant="destructive" className="animate-pulse cursor-help">
|
||||
<Badge variant="destructive" className="animate-pulse cursor-help text-xs">
|
||||
Update Available
|
||||
</Badge>
|
||||
<div className="absolute top-full left-1/2 transform -translate-x-1/2 mt-2 px-3 py-2 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded-lg shadow-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none whitespace-nowrap z-10">
|
||||
<div className="absolute top-full left-1/2 transform -translate-x-1/2 mt-2 px-3 py-2 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded-lg shadow-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none whitespace-nowrap z-10 hidden sm:block">
|
||||
<div className="text-center">
|
||||
<div className="font-semibold mb-1">How to update:</div>
|
||||
<div>Click the button to update, when installed via the helper script</div>
|
||||
@@ -255,38 +255,42 @@ export function VersionDisplay() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleUpdate}
|
||||
disabled={isUpdating}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="text-xs h-6 px-2"
|
||||
>
|
||||
{isUpdating ? (
|
||||
<>
|
||||
<RefreshCw className="h-3 w-3 mr-1 animate-spin" />
|
||||
Updating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-3 w-3 mr-1" />
|
||||
Update Now
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<a
|
||||
href={releaseInfo.htmlUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="View latest release"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handleUpdate}
|
||||
disabled={isUpdating}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="text-xs h-6 px-2"
|
||||
>
|
||||
{isUpdating ? (
|
||||
<>
|
||||
<RefreshCw className="h-3 w-3 mr-1 animate-spin" />
|
||||
<span className="hidden sm:inline">Updating...</span>
|
||||
<span className="sm:hidden">...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-3 w-3 mr-1" />
|
||||
<span className="hidden sm:inline">Update Now</span>
|
||||
<span className="sm:hidden">Update</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<a
|
||||
href={releaseInfo.htmlUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="View latest release"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{updateResult && (
|
||||
<div className={`text-xs px-2 py-1 rounded ${
|
||||
<div className={`text-xs px-2 py-1 rounded text-center ${
|
||||
updateResult.success
|
||||
? 'bg-chart-2/20 text-chart-2 border border-chart-2/30'
|
||||
: 'bg-destructive/20 text-destructive border border-destructive/30'
|
||||
|
||||
@@ -7,7 +7,8 @@ import { TRPCReactProvider } from "~/trpc/react";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "PVE Scripts local",
|
||||
description: "",
|
||||
description: "Manage and execute Proxmox helper scripts locally with live output streaming",
|
||||
viewport: "width=device-width, initial-scale=1, maximum-scale=1",
|
||||
icons: [
|
||||
{ rel: "icon", url: "/favicon.png", type: "image/png" },
|
||||
{ rel: "icon", url: "/favicon.ico", sizes: "any" },
|
||||
|
||||
@@ -26,24 +26,24 @@ export default function Home() {
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="container mx-auto px-2 sm:px-4 py-4 sm:py-8">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-4xl font-bold text-foreground mb-2 flex items-center justify-center gap-3">
|
||||
<Rocket className="h-9 w-9" />
|
||||
PVE Scripts Management
|
||||
<div className="text-center mb-6 sm:mb-8">
|
||||
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-foreground mb-2 flex items-center justify-center gap-2 sm:gap-3">
|
||||
<Rocket className="h-6 w-6 sm:h-8 w-8 lg:h-9 lg:w-9" />
|
||||
<span className="break-words">PVE Scripts Management</span>
|
||||
</h1>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
<p className="text-sm sm:text-base text-muted-foreground mb-4 px-2">
|
||||
Manage and execute Proxmox helper scripts locally with live output streaming
|
||||
</p>
|
||||
<div className="flex justify-center">
|
||||
<div className="flex justify-center px-2">
|
||||
<VersionDisplay />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="mb-8">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-6 p-6 bg-card rounded-lg shadow-sm border border-border">
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<div className="flex flex-col gap-4 p-4 sm:p-6 bg-card rounded-lg shadow-sm border border-border">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<SettingsButton />
|
||||
</div>
|
||||
@@ -54,44 +54,47 @@ export default function Home() {
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="mb-8">
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<div className="border-b border-border">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
<nav className="-mb-px flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-2 lg:space-x-8">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="null"
|
||||
onClick={() => setActiveTab('scripts')}
|
||||
className={`px-3 py-1 text-sm flex items-center gap-2 ${
|
||||
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
|
||||
activeTab === 'scripts'
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'hover:bg-accent hover:text-accent-foreground'
|
||||
}`}>
|
||||
<Package className="h-4 w-4" />
|
||||
Available Scripts
|
||||
<span className="hidden sm:inline">Available Scripts</span>
|
||||
<span className="sm:hidden">Available</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="null"
|
||||
onClick={() => setActiveTab('downloaded')}
|
||||
className={`px-3 py-1 text-sm flex items-center gap-2 ${
|
||||
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
|
||||
activeTab === 'downloaded'
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'hover:bg-accent hover:text-accent-foreground'
|
||||
}`}>
|
||||
<HardDrive className="h-4 w-4" />
|
||||
Downloaded Scripts
|
||||
<span className="hidden sm:inline">Downloaded Scripts</span>
|
||||
<span className="sm:hidden">Downloaded</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="null"
|
||||
onClick={() => setActiveTab('installed')}
|
||||
className={`px-3 py-1 text-sm flex items-center gap-2 ${
|
||||
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
|
||||
activeTab === 'installed'
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'hover:bg-accent hover:text-accent-foreground'
|
||||
}`}>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
Installed Scripts
|
||||
<span className="hidden sm:inline">Installed Scripts</span>
|
||||
<span className="sm:hidden">Installed</span>
|
||||
</Button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -24,9 +24,12 @@ export class ScriptExecutionHandler {
|
||||
}
|
||||
|
||||
private handleConnection(ws: WebSocket, _request: IncomingMessage) {
|
||||
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
|
||||
|
||||
const message = JSON.parse(data.toString()) as { action: string; scriptPath?: string; executionId?: string };
|
||||
void this.handleMessage(ws, message);
|
||||
} catch (error) {
|
||||
@@ -40,20 +43,20 @@ export class ScriptExecutionHandler {
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
|
||||
// Clean up any active executions for this connection
|
||||
this.cleanupActiveExecutions(ws);
|
||||
});
|
||||
|
||||
ws.on('error', (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
ws.on('error', (_error) => {
|
||||
this.cleanupActiveExecutions(ws);
|
||||
});
|
||||
}
|
||||
|
||||
private async handleMessage(ws: WebSocket, message: { action: string; scriptPath?: string; executionId?: string; mode?: 'local' | 'ssh'; server?: any }) {
|
||||
const { action, scriptPath, executionId, mode, server } = message;
|
||||
private async handleMessage(ws: WebSocket, message: { action: string; scriptPath?: string; executionId?: string; mode?: 'local' | 'ssh'; server?: any; input?: string }) {
|
||||
const { action, scriptPath, executionId, mode, server, input } = message;
|
||||
|
||||
console.log('WebSocket message received:', { action, scriptPath, executionId, mode, server: server ? { name: server.name, ip: server.ip } : null });
|
||||
|
||||
|
||||
switch (action) {
|
||||
case 'start':
|
||||
@@ -74,6 +77,20 @@ export class ScriptExecutionHandler {
|
||||
}
|
||||
break;
|
||||
|
||||
case 'input':
|
||||
if (executionId && input !== undefined) {
|
||||
|
||||
this.sendInputToExecution(executionId, input);
|
||||
} else {
|
||||
|
||||
this.sendMessage(ws, {
|
||||
type: 'error',
|
||||
data: 'Missing executionId or input data',
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
this.sendMessage(ws, {
|
||||
type: 'error',
|
||||
@@ -84,8 +101,7 @@ export class ScriptExecutionHandler {
|
||||
}
|
||||
|
||||
private async startScriptExecution(ws: WebSocket, scriptPath: string, executionId: string, mode?: 'local' | 'ssh', server?: any) {
|
||||
console.log('startScriptExecution called with:', { scriptPath, executionId, mode, server: server ? { name: server.name, ip: server.ip } : null });
|
||||
|
||||
|
||||
try {
|
||||
// Check if execution is already running
|
||||
if (this.activeExecutions.has(executionId)) {
|
||||
@@ -100,10 +116,7 @@ export class ScriptExecutionHandler {
|
||||
let process: any;
|
||||
|
||||
if (mode === 'ssh' && server) {
|
||||
// SSH execution
|
||||
console.log('Starting SSH execution:', { scriptPath, server });
|
||||
console.log('SSH execution mode detected, calling SSH service...');
|
||||
console.log('Mode check: mode=', mode, 'server=', !!server);
|
||||
|
||||
this.sendMessage(ws, {
|
||||
type: 'start',
|
||||
data: `Starting SSH execution of ${scriptPath} on ${server.name ?? server.ip}`,
|
||||
@@ -111,13 +124,11 @@ export class ScriptExecutionHandler {
|
||||
});
|
||||
|
||||
const sshService = getSSHExecutionService();
|
||||
console.log('SSH service obtained, calling executeScript...');
|
||||
console.log('SSH service object:', typeof sshService, sshService.constructor.name);
|
||||
|
||||
|
||||
try {
|
||||
const result = await sshService.executeScript(server as Server, scriptPath,
|
||||
(data: string) => {
|
||||
console.log('SSH onData callback:', data.substring(0, 100) + '...');
|
||||
|
||||
this.sendMessage(ws, {
|
||||
type: 'output',
|
||||
data: data,
|
||||
@@ -125,7 +136,7 @@ export class ScriptExecutionHandler {
|
||||
});
|
||||
},
|
||||
(error: string) => {
|
||||
console.log('SSH onError callback:', error);
|
||||
|
||||
this.sendMessage(ws, {
|
||||
type: 'error',
|
||||
data: error,
|
||||
@@ -133,7 +144,7 @@ export class ScriptExecutionHandler {
|
||||
});
|
||||
},
|
||||
(code: number) => {
|
||||
console.log('SSH onExit callback, code:', code);
|
||||
|
||||
this.sendMessage(ws, {
|
||||
type: 'end',
|
||||
data: `SSH script execution finished with code: ${code}`,
|
||||
@@ -142,10 +153,10 @@ export class ScriptExecutionHandler {
|
||||
this.activeExecutions.delete(executionId);
|
||||
}
|
||||
);
|
||||
console.log('SSH service executeScript completed, result:', result);
|
||||
|
||||
process = (result as any).process;
|
||||
} catch (sshError) {
|
||||
console.error('SSH service executeScript failed:', sshError);
|
||||
|
||||
this.sendMessage(ws, {
|
||||
type: 'error',
|
||||
data: `SSH execution failed: ${sshError instanceof Error ? sshError.message : String(sshError)}`,
|
||||
@@ -154,10 +165,7 @@ export class ScriptExecutionHandler {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Local execution
|
||||
console.log('Starting local execution:', { scriptPath });
|
||||
console.log('Local execution mode detected, calling local script manager...');
|
||||
console.log('Mode check: mode=', mode, 'server=', !!server, 'condition result:', mode === 'ssh' && server);
|
||||
|
||||
|
||||
// Validate script path
|
||||
const validation = scriptManager.validateScriptPath(scriptPath);
|
||||
@@ -249,6 +257,59 @@ export class ScriptExecutionHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private sendInputToExecution(executionId: string, input: string) {
|
||||
|
||||
const execution = this.activeExecutions.get(executionId);
|
||||
|
||||
|
||||
if (execution?.process) {
|
||||
|
||||
try {
|
||||
// Check if it's a pty process (SSH) or regular process
|
||||
if (typeof execution.process.write === 'function' && !execution.process.stdin) {
|
||||
|
||||
|
||||
execution.process.write(input);
|
||||
|
||||
|
||||
// Send confirmation back to client
|
||||
this.sendMessage(execution.ws, {
|
||||
type: 'output',
|
||||
data: `[MOBILE INPUT SENT: ${JSON.stringify(input)}]`,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
} else if (execution.process.stdin && !execution.process.stdin.destroyed) {
|
||||
|
||||
execution.process.stdin.write(input);
|
||||
|
||||
|
||||
this.sendMessage(execution.ws, {
|
||||
type: 'output',
|
||||
data: `[MOBILE INPUT SENT: ${JSON.stringify(input)}]`,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
} else {
|
||||
|
||||
this.sendMessage(execution.ws, {
|
||||
type: 'error',
|
||||
data: 'Process input not available',
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
this.sendMessage(execution.ws, {
|
||||
type: 'error',
|
||||
data: `Failed to send input: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// No active execution found - this case is already handled above
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private sendMessage(ws: WebSocket, message: ScriptExecutionMessage) {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(message));
|
||||
|
||||
@@ -141,3 +141,75 @@
|
||||
color: inherit;
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
/* Mobile-specific improvements */
|
||||
@media (max-width: 640px) {
|
||||
/* Improve touch targets */
|
||||
button, .cursor-pointer {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
/* Better text sizing on mobile */
|
||||
.text-xs {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
/* Improve form elements on mobile */
|
||||
input, select, textarea {
|
||||
font-size: 16px; /* Prevents zoom on iOS */
|
||||
}
|
||||
|
||||
/* Better spacing for mobile */
|
||||
.space-y-2 > * + * {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.space-y-4 > * + * {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Improve modal and overlay positioning */
|
||||
.fixed.inset-0 {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Better scroll behavior */
|
||||
.overflow-x-auto {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablet improvements */
|
||||
@media (min-width: 641px) and (max-width: 1024px) {
|
||||
/* Better spacing for tablets */
|
||||
.container {
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure proper viewport handling */
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Mobile terminal centering - simple approach */
|
||||
.mobile-terminal {
|
||||
display: flex !important;
|
||||
justify-content: center !important;
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
.mobile-terminal .xterm {
|
||||
margin: 0 auto !important;
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user