feat: Add UI Access button and rearrange the Action Buttons in a Dropdown. (#146)

* feat: Add Web UI IP:Port tracking and access functionality

- Add web_ui_ip and web_ui_port columns to installed_scripts table with migration
- Update database CRUD methods to handle new Web UI fields
- Add terminal output parsing to auto-detect Web UI URLs during installation
- Create autoDetectWebUI mutation that runs hostname -I in containers via SSH
- Add Web UI column to desktop table with editable IP and port fields
- Add Open UI button that opens http://ip:port in new tab
- Add Re-detect button for manual IP detection using script metadata
- Update mobile card view with Web UI fields and buttons
- Fix nested button hydration error in ContextualHelpIcon
- Prioritize script metadata interface_port over existing database values
- Use pct exec instead of pct enter for container command execution
- Add comprehensive error handling and user feedback
- Style auto-detect button with muted colors and Re-detect text

Features:
- Automatic Web UI detection during script installation
- Manual IP detection with port lookup from script metadata
- Editable IP and port fields in both desktop and mobile views
- Clickable Web UI links that open in new tabs
- Support for both local and SSH script executions
- Proper port detection from script JSON metadata (e.g., actualbudget:5006)
- Clean UI with subtle button styling and clear text labels

* feat: Disable Open UI button when container is stopped

- Add disabled state to Open UI button in desktop table when container is stopped
- Update mobile card Open UI button to be disabled when container is stopped
- Apply consistent styling with Shell and Update buttons
- Prevent users from accessing Web UI when container is not running
- Add cursor-not-allowed styling for disabled clickable IP links

* feat: Align Re-detect buttons consistently in Web UI column

- Change flex layout from space-x-2 to justify-between for consistent button alignment
- Add flex-shrink-0 to prevent IP:port text and buttons from shrinking
- Add ml-2 margin to Re-detect button for proper spacing
- Apply changes to both desktop table and mobile card views
- Buttons now align vertically regardless of IP:port text length

* feat: Add actions dropdown menu with conditional Start/Stop colors and update help

- Create dropdown-menu.tsx component using Radix UI primitives
- Move all action buttons except Edit into dropdown menu
- Keep Edit and Save/Cancel buttons always visible
- Add conditional styling: Start (green), Stop (red)
- Apply changes to both desktop table and mobile card views
- Add smart visibility - dropdown only shows when actions available
- Auto-close dropdown after clicking any action
- Style dropdown to match existing button theme
- Fix syntax error in dropdown-menu.tsx component
- Update help section with Web UI Access and Actions Dropdown documentation
- Add detailed explanations of auto-detection, IP/port tracking, and color coding

* Fix TypeScript build error in server.js

- Updated parseWebUIUrl JSDoc return type from Object|null to {ip: string, port: number}|null
- This fixes the TypeScript error where 'ip' property was not recognized on type 'Object'
- Build now completes successfully without errors
This commit is contained in:
Michel Roegl-Brunner
2025-10-14 15:35:21 +02:00
committed by GitHub
parent 58e1fb3cea
commit ceef5c7bb9
11 changed files with 1608 additions and 139 deletions

View File

@@ -83,7 +83,9 @@ export const installedScriptsRouter = createTRPCRouter({
server_id: z.number().optional(),
execution_mode: z.enum(['local', 'ssh']),
status: z.enum(['in_progress', 'success', 'failed']),
output_log: z.string().optional()
output_log: z.string().optional(),
web_ui_ip: z.string().optional(),
web_ui_port: z.number().optional()
}))
.mutation(async ({ input }) => {
try {
@@ -110,7 +112,9 @@ export const installedScriptsRouter = createTRPCRouter({
script_name: z.string().optional(),
container_id: z.string().optional(),
status: z.enum(['in_progress', 'success', 'failed']).optional(),
output_log: z.string().optional()
output_log: z.string().optional(),
web_ui_ip: z.string().optional(),
web_ui_port: z.number().optional()
}))
.mutation(async ({ input }) => {
try {
@@ -972,5 +976,177 @@ export const installedScriptsRouter = createTRPCRouter({
error: error instanceof Error ? error.message : 'Failed to destroy container'
};
}
}),
// Auto-detect Web UI IP and port
autoDetectWebUI: publicProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ input }) => {
try {
console.log('🔍 Auto-detect WebUI called with id:', input.id);
const db = getDatabase();
const script = db.getInstalledScriptById(input.id);
if (!script) {
console.log('❌ Script not found for id:', input.id);
return {
success: false,
error: 'Script not found'
};
}
const scriptData = script as any;
console.log('📋 Script data:', {
id: scriptData.id,
execution_mode: scriptData.execution_mode,
server_id: scriptData.server_id,
container_id: scriptData.container_id
});
// Only works for SSH mode scripts with container_id
if (scriptData.execution_mode !== 'ssh' || !scriptData.server_id || !scriptData.container_id) {
console.log('❌ Validation failed - not SSH mode or missing server/container ID');
return {
success: false,
error: 'Auto-detect only works for SSH mode scripts with container ID'
};
}
// Get server info
const server = db.getServerById(Number(scriptData.server_id));
if (!server) {
console.log('❌ Server not found for id:', scriptData.server_id);
return {
success: false,
error: 'Server not found'
};
}
console.log('🖥️ Server found:', { id: (server as any).id, name: (server as any).name, ip: (server as any).ip });
// Import SSH services
const { default: SSHService } = await import('~/server/ssh-service');
const { default: SSHExecutionService } = await import('~/server/ssh-execution-service');
const sshService = new SSHService();
const sshExecutionService = new SSHExecutionService();
// Test SSH connection first
console.log('🔌 Testing SSH connection...');
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const connectionTest = await sshService.testSSHConnection(server as any);
if (!(connectionTest as any).success) {
console.log('❌ SSH connection failed:', (connectionTest as any).error);
return {
success: false,
error: `SSH connection failed: ${(connectionTest as any).error ?? 'Unknown error'}`
};
}
console.log('✅ SSH connection successful');
// Run hostname -I inside the container
// Use pct exec instead of pct enter -c (which doesn't exist)
const hostnameCommand = `pct exec ${scriptData.container_id} -- hostname -I`;
console.log('🚀 Running command:', hostnameCommand);
let commandOutput = '';
await new Promise<void>((resolve, reject) => {
void sshExecutionService.executeCommand(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
server as any,
hostnameCommand,
(data: string) => {
console.log('📤 Command output chunk:', data);
commandOutput += data;
},
(error: string) => {
console.log('❌ Command error:', error);
reject(new Error(error));
},
(exitCode: number) => {
console.log('🏁 Command finished with exit code:', exitCode);
if (exitCode !== 0) {
reject(new Error(`Command failed with exit code ${exitCode}`));
} else {
resolve();
}
}
);
});
// Parse output to get first IP address
console.log('📝 Full command output:', commandOutput);
const ips = commandOutput.trim().split(/\s+/);
const detectedIp = ips[0];
console.log('🔍 Parsed IPs:', ips);
console.log('🎯 Detected IP:', detectedIp);
if (!detectedIp || !/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.exec(detectedIp)) {
console.log('❌ Invalid IP address detected:', detectedIp);
return {
success: false,
error: 'Could not detect valid IP address from container'
};
}
// Get the script's interface_port from metadata (prioritize metadata over existing database values)
let detectedPort = 80; // Default fallback
try {
// Import localScriptsService to get script metadata
const { localScriptsService } = await import('~/server/services/localScripts');
// Get all scripts and find the one matching our script name
const allScripts = await localScriptsService.getAllScripts();
// Extract script slug from script_name (remove .sh extension)
const scriptSlug = scriptData.script_name.replace(/\.sh$/, '');
console.log('🔍 Looking for script with slug:', scriptSlug);
const scriptMetadata = allScripts.find(script => script.slug === scriptSlug);
if (scriptMetadata?.interface_port) {
detectedPort = scriptMetadata.interface_port;
console.log('📋 Found interface_port in metadata:', detectedPort);
} else {
console.log('📋 No interface_port found in metadata, using default port 80');
detectedPort = 80; // Default to port 80 if no metadata port found
}
} catch (error) {
console.log('⚠️ Error getting script metadata, using default port 80:', error);
detectedPort = 80; // Default to port 80 if metadata lookup fails
}
console.log('🎯 Final detected port:', detectedPort);
// Update the database with detected IP and port
console.log('💾 Updating database with IP:', detectedIp, 'Port:', detectedPort);
const updateResult = db.updateInstalledScript(input.id, {
web_ui_ip: detectedIp,
web_ui_port: detectedPort
});
if (updateResult.changes === 0) {
console.log('❌ Database update failed - no changes made');
return {
success: false,
error: 'Failed to update database with detected IP'
};
}
console.log('✅ Successfully updated database');
return {
success: true,
message: `Successfully detected IP: ${detectedIp}:${detectedPort}`,
detectedIp,
detectedPort: detectedPort
};
} catch (error) {
console.error('Error in autoDetectWebUI:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to auto-detect Web UI IP'
};
}
})
});