@tb.p/terminai
Version:
MCP (Model Context Protocol) server for secure SSH remote command execution. Enables AI assistants like Claude, Cursor, and VS Code to execute commands on remote servers via SSH with command validation, history tracking, and web-based configuration UI.
1,072 lines (960 loc) โข 56.1 kB
HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Terminai</title>
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
</head>
<body class="bg-gray-900">
<div x-data="app()" x-init="init()" class="container mx-auto p-6 max-w-6xl">
<div class="bg-gray-800 rounded-lg shadow-lg p-6 mb-6">
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold text-gray-100">Terminai</h1>
<button @click="saveConfig()" class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded">
Save Configuration
</button>
</div>
<div class="mb-8">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-gray-100">Global Rules</h2>
<button @click="globalRulesExpanded = !globalRulesExpanded" class="text-gray-400 hover:text-gray-200">
<span x-show="!globalRulesExpanded">โผ</span>
<span x-show="globalRulesExpanded">โฒ</span>
</button>
</div>
<div x-show="globalRulesExpanded" class="bg-gray-800 p-4 rounded border border-gray-700">
<div class="mb-4">
<div class="flex justify-between items-center mb-2">
<label class="block text-sm font-medium text-gray-200">Allowed Commands</label>
<button @click="addGlobalAllowedCommand()" class="text-green-500 hover:text-green-600 text-sm">+ Add</button>
</div>
<div class="space-y-2">
<template x-for="(cmd, index) in globalAllowedCommands" :key="index">
<div class="flex gap-2">
<input type="text" x-model="globalAllowedCommands[index]" class="flex-1 px-3 py-2 bg-gray-700 border border-gray-600 text-gray-100 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 placeholder:text-gray-500">
<button @click="removeGlobalAllowedCommand(index)" class="text-red-500 hover:text-red-600 px-3">×</button>
</div>
</template>
</div>
</div>
<div class="mb-4">
<div class="flex justify-between items-center mb-2">
<label class="block text-sm font-medium text-gray-200">Denied Commands</label>
<button @click="addGlobalDeniedCommand()" class="text-green-500 hover:text-green-600 text-sm">+ Add</button>
</div>
<div class="space-y-2">
<template x-for="(cmd, index) in globalDeniedCommands" :key="index">
<div class="flex gap-2">
<input type="text" x-model="globalDeniedCommands[index]" class="flex-1 px-3 py-2 bg-gray-700 border border-gray-600 text-gray-100 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 placeholder:text-gray-500">
<button @click="removeGlobalDeniedCommand(index)" class="text-red-500 hover:text-red-600 px-3">×</button>
</div>
</template>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-200 mb-2">Default Key Location</label>
<div class="flex gap-2">
<input type="text" x-model="defaultKeyLocation" class="flex-1 px-3 py-2 bg-gray-700 border border-gray-600 text-gray-100 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 placeholder:text-gray-500" placeholder="~/.ssh">
<button @click="openFileBrowserForDefaultLocation()" class="px-4 py-2 border border-gray-500 rounded hover:bg-gray-600 text-gray-200">
Browse
</button>
<button type="button" @click.stop="openInExplorer(defaultKeyLocation)" class="px-4 py-2 border border-gray-500 rounded hover:bg-gray-600 text-gray-200" title="Open in Explorer">
๐
</button>
</div>
</div>
</div>
</div>
<div>
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold text-gray-100">Connections</h2>
<button @click="openAddConnection()" class="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded">
+ Add New
</button>
</div>
<div class="space-y-4">
<template x-for="conn in connections" :key="conn.name">
<div class="border border-gray-600 rounded-lg p-4 hover:shadow-lg transition-shadow bg-gray-800 hover:bg-gray-700">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" :checked="conn.active !== false" @change="toggleConnection(conn.name, $event.target.checked)" class="sr-only peer">
<div class="w-11 h-6 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-focus:outline-none peer-focus:ring-4"
:class="getToggleColor(conn)">
</div>
</label>
<h3 class="font-semibold text-lg text-gray-100" :class="{'opacity-50': conn.active === false}" x-text="conn.name"></h3>
<span class="text-gray-300 text-sm" :class="{'opacity-50': conn.active === false}" x-text="`${conn.username}@${conn.host}`"></span>
</div>
<p class="text-gray-300 text-sm" :class="{'opacity-50': conn.active === false}" x-text="conn.description"></p>
</div>
<div class="flex gap-2">
<button @click="testConnection(conn.name)" class="text-blue-500 hover:text-blue-600 px-3 py-1 text-sm border border-blue-500 rounded">
Test
</button>
<button @click="openEditConnection(conn)" class="text-gray-300 hover:text-gray-100 px-3 py-1 text-sm border border-gray-500 rounded hover:bg-gray-700">
View
</button>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
<div x-show="showModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" @click.self="closeModal()">
<div class="bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-hidden flex flex-col">
<div class="flex justify-between items-center p-6 border-b">
<h2 class="text-2xl font-bold text-gray-100" x-text="modalTitle"></h2>
<button @click="closeModal()" class="text-gray-400 hover:text-gray-200 text-2xl">×</button>
</div>
<div class="flex border-b">
<button @click="activeTab = 'settings'" :class="activeTab === 'settings' ? 'border-b-2 border-blue-500 text-blue-400' : 'text-gray-400'" class="px-6 py-3 font-medium">
Settings
</button>
<button @click="activeTab = 'test'" :class="activeTab === 'test' ? 'border-b-2 border-blue-500 text-blue-400' : 'text-gray-400'" class="px-6 py-3 font-medium" x-show="editingConnection">
Test Command
</button>
<button @click="activeTab = 'history'" :class="activeTab === 'history' ? 'border-b-2 border-blue-500 text-blue-400' : 'text-gray-400'" class="px-6 py-3 font-medium" x-show="editingConnection">
History
</button>
</div>
<div class="flex-1 overflow-y-auto p-6">
<div x-show="activeTab === 'settings'" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-200 mb-2">Name</label>
<input type="text" x-model="currentConnection.name" class="w-full px-3 py-2 bg-gray-700 border border-gray-600 text-gray-100 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 placeholder:text-gray-500" :disabled="editingConnection">
</div>
<div>
<label class="block text-sm font-medium text-gray-200 mb-2">Host</label>
<input type="text" x-model="currentConnection.host" class="w-full px-3 py-2 bg-gray-700 border border-gray-600 text-gray-100 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 placeholder:text-gray-500">
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-200 mb-2">Port</label>
<input type="number" x-model="currentConnection.port" class="w-full px-3 py-2 bg-gray-700 border border-gray-600 text-gray-100 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 placeholder:text-gray-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-200 mb-2">Username</label>
<input type="text" x-model="currentConnection.username" class="w-full px-3 py-2 bg-gray-700 border border-gray-600 text-gray-100 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 placeholder:text-gray-500">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-200 mb-2">Description (helps AI select this connection)</label>
<textarea x-model="currentConnection.description" rows="3" class="w-full px-3 py-2 bg-gray-700 border border-gray-600 text-gray-100 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 placeholder:text-gray-500" placeholder="e.g., Production web server running nginx. Use for checking service status..."></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-200 mb-2">Identity File</label>
<div class="flex gap-2">
<input type="text" x-model="currentConnection.identityFile" class="flex-1 px-3 py-2 bg-gray-700 border border-gray-600 text-gray-100 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 placeholder:text-gray-500">
<button @click="openFileBrowser()" class="px-4 py-2 border border-gray-500 rounded hover:bg-gray-700 text-gray-200">
Browse
</button>
<button type="button" @click.stop="openInExplorer(currentConnection.identityFile)" class="px-4 py-2 border border-gray-500 rounded hover:bg-gray-700 text-gray-200" title="Open in Explorer">
๐
</button>
<button @click="openKeyGenerator()" class="px-4 py-2 border border-gray-500 rounded hover:bg-gray-700 text-gray-200">
Generate
</button>
</div>
</div>
<div x-show="publicKey" class="bg-gray-800 p-3 rounded border border-gray-700">
<div class="flex justify-between items-center mb-2">
<label class="block text-sm font-medium text-gray-200">Public Key</label>
<button @click="copyToClipboard(publicKey)" class="text-blue-400 hover:text-blue-300 text-sm">Copy</button>
</div>
<pre class="text-xs bg-gray-900 p-2 rounded border border-gray-600 text-gray-100 overflow-x-auto" x-text="publicKey"></pre>
</div>
<div>
<div class="flex justify-between items-center mb-2">
<label class="block text-sm font-medium text-gray-700">Allowed Commands</label>
<button @click="addAllowedCommand()" class="text-green-500 hover:text-green-600 text-sm">+ Add</button>
</div>
<div class="space-y-2">
<template x-for="(cmd, index) in currentConnection.allowedCommands" :key="index">
<div class="flex gap-2">
<input type="text" x-model="currentConnection.allowedCommands[index]" class="flex-1 px-3 py-2 bg-gray-700 border border-gray-600 text-gray-100 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 placeholder:text-gray-500">
<button @click="removeAllowedCommand(index)" class="text-red-500 hover:text-red-600 px-3">×</button>
</div>
</template>
</div>
</div>
<div>
<div class="flex justify-between items-center mb-2">
<label class="block text-sm font-medium text-gray-700">Disallowed Commands</label>
<button @click="addDisallowedCommand()" class="text-green-500 hover:text-green-600 text-sm">+ Add</button>
</div>
<div class="space-y-2">
<template x-for="(cmd, index) in currentConnection.disallowedCommands" :key="index">
<div class="flex gap-2">
<input type="text" x-model="currentConnection.disallowedCommands[index]" class="flex-1 px-3 py-2 bg-gray-700 border border-gray-600 text-gray-100 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 placeholder:text-gray-500">
<button @click="removeDisallowedCommand(index)" class="text-red-500 hover:text-red-600 px-3">×</button>
</div>
</template>
</div>
</div>
</div>
<div x-show="activeTab === 'test'" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-200 mb-2">Test Command</label>
<div class="flex gap-2">
<input
type="text"
x-model="testCommand"
@keyup.enter="testCommandValidation()"
placeholder="Enter a command to test (supports regex patterns like /^rm\\s+-rf/)"
class="flex-1 px-3 py-2 bg-gray-700 border border-gray-600 text-gray-100 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 placeholder:text-gray-500"
>
<button @click="testCommandValidation()" class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded">
Test
</button>
</div>
</div>
<div x-show="testResult" class="border rounded-lg p-4" :class="testResult && testResult.allowed ? 'border-green-500 bg-green-900/30' : 'border-red-500 bg-red-900/30'">
<div class="flex items-center gap-2 mb-2">
<span x-text="testResult && testResult.allowed ? 'โ' : 'โ'" class="text-2xl font-bold" :class="testResult && testResult.allowed ? 'text-green-400' : 'text-red-400'"></span>
<span class="font-semibold" :class="testResult && testResult.allowed ? 'text-green-300' : 'text-red-300'" x-text="testResult && testResult.allowed ? 'Command Allowed' : 'Command Denied'"></span>
</div>
<div class="text-sm mb-3" :class="testResult && testResult.allowed ? 'text-green-300' : 'text-red-300'">
<p x-text="testResult && testResult.reason || ''"></p>
</div>
<div x-show="testResult && testResult.matchedRule" class="mt-3 pt-3 border-t" :class="testResult && testResult.allowed ? 'border-green-600' : 'border-red-600'">
<div class="text-xs font-medium mb-1" :class="testResult && testResult.allowed ? 'text-green-300' : 'text-red-300'">Matched Rule:</div>
<div class="flex items-center gap-2">
<code class="text-sm bg-gray-900 px-2 py-1 rounded border text-gray-100" :class="testResult && testResult.allowed ? 'border-green-600' : 'border-red-600'" x-text="testResult && testResult.matchedRule || ''"></code>
<span class="text-xs px-2 py-1 rounded"
:class="{
'bg-blue-100 text-blue-800': testResult && testResult.matchedRuleType === 'connection-allowed',
'bg-green-100 text-green-800': testResult && testResult.matchedRuleType === 'global-allowed',
'bg-orange-100 text-orange-800': testResult && testResult.matchedRuleType === 'connection-denied',
'bg-red-100 text-red-800': testResult && testResult.matchedRuleType === 'global-denied'
}"
x-text="testResult && testResult.matchedRuleType ? ({
'connection-allowed': 'Connection Allowed',
'global-allowed': 'Global Allowed',
'connection-denied': 'Connection Denied',
'global-denied': 'Global Denied'
}[testResult.matchedRuleType] || testResult.matchedRuleType) : ''"
></span>
</div>
</div>
</div>
<div class="text-sm text-gray-200 bg-gray-800 p-3 rounded border border-gray-700">
<p class="font-medium mb-2 text-gray-100">Priority Order (least to highest):</p>
<ol class="list-decimal list-inside space-y-1 text-xs text-gray-300">
<li>Global Denied - Broadest restrictions</li>
<li>Global Allowed - Can override global denied</li>
<li>Connection Denied - More specific restrictions</li>
<li>Connection Allowed - Most specific, can override everything</li>
<li><strong>Default: Allow</strong> - If no rules match, command is allowed</li>
</ol>
<p class="mt-3 text-xs text-gray-400">
<strong>Note:</strong> You can use regex patterns by wrapping them in forward slashes, e.g., <code>/^rm\s+-rf/</code> or <code>/^sudo\s+/i</code> (case-insensitive).
</p>
</div>
</div>
<div x-show="activeTab === 'history'" class="space-y-4">
<div class="flex justify-between items-center">
<input type="text" x-model="historyFilter" placeholder="Filter commands..." class="flex-1 px-3 py-2 bg-gray-700 border border-gray-600 text-gray-100 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 placeholder:text-gray-500 mr-4">
<button @click="confirmClearHistory()" class="text-red-500 hover:text-red-600 px-4 py-2 border border-red-500 rounded">
Clear History
</button>
</div>
<div class="space-y-3">
<template x-for="entry in filteredHistory" :key="entry.timestamp">
<div class="border border-gray-700 rounded p-3 bg-gray-800">
<div class="flex items-start justify-between mb-2">
<div class="flex items-center gap-2">
<span x-text="getHistoryIcon(entry.status)"></span>
<code class="text-sm text-gray-200" x-text="entry.command"></code>
</div>
<span class="text-xs text-gray-400" x-text="formatTimestamp(entry.timestamp)"></span>
</div>
<div class="text-xs text-gray-400 mb-2">
<span x-text="`${entry.duration}ms`"></span>
<span x-show="entry.exitCode !== null" x-text="`ยท exit ${entry.exitCode}`"></span>
</div>
<div x-show="entry.stdout" class="mt-2">
<div class="flex justify-between items-center mb-1">
<span class="text-xs font-medium text-gray-300">stdout</span>
<button @click="copyToClipboard(entry.stdout)" class="text-blue-400 hover:text-blue-300 text-xs">Copy</button>
</div>
<pre class="text-xs bg-gray-900 p-2 rounded border border-gray-700 text-gray-100 overflow-x-auto max-h-40" x-text="entry.stdout"></pre>
</div>
<div x-show="entry.stderr" class="mt-2">
<div class="flex justify-between items-center mb-1">
<span class="text-xs font-medium text-gray-300">stderr</span>
<button @click="copyToClipboard(entry.stderr)" class="text-blue-400 hover:text-blue-300 text-xs">Copy</button>
</div>
<pre class="text-xs bg-red-900/30 p-2 rounded border border-red-700 text-gray-100 overflow-x-auto max-h-40" x-text="entry.stderr"></pre>
</div>
</div>
</template>
</div>
</div>
</div>
<div class="flex justify-between items-center p-6 border-t">
<button x-show="editingConnection" @click="confirmDeleteConnection()" class="text-red-500 hover:text-red-600 px-4 py-2 border border-red-500 rounded">
Delete
</button>
<div class="flex gap-2 ml-auto">
<button @click="closeModal()" class="px-4 py-2 border border-gray-500 rounded hover:bg-gray-700 text-gray-200">
Cancel
</button>
<button x-show="activeTab === 'settings' && editingConnection" @click="testConnectionInModal()" class="px-4 py-2 border border-blue-500 text-blue-400 rounded hover:bg-blue-900/30">
Test Connection
</button>
<button x-show="activeTab === 'settings'" @click="saveConnection()" class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded">
Save
</button>
</div>
</div>
</div>
</div>
<div x-show="showFileBrowser" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" @click.self="closeFileBrowser()">
<div class="bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full mx-4">
<div class="flex justify-between items-center p-6 border-b border-gray-700">
<h2 class="text-xl font-bold text-gray-100" x-text="fileBrowserForKeyGen ? 'Select Key Location' : (fileBrowserForDefaultLocation ? 'Select Default Key Location' : 'Select Identity File')"></h2>
<button @click="closeFileBrowser()" class="text-gray-400 hover:text-gray-200 text-2xl">×</button>
</div>
<div class="p-6">
<div class="flex items-center justify-between mb-4">
<code class="text-sm text-gray-200" x-text="currentPath"></code>
<div class="flex gap-2">
<button x-show="fileBrowserForKeyGen || fileBrowserForDefaultLocation" @click="selectCurrentDirectory()" class="px-3 py-1 border border-green-500 rounded hover:bg-green-900/30 text-green-400">
Select This Directory
</button>
<button @click="navigateUp()" class="px-3 py-1 border border-gray-500 rounded hover:bg-gray-700 text-gray-200">
โ Up
</button>
</div>
</div>
<div x-show="showCreateDir" class="mb-4 p-3 bg-gray-700 rounded border border-gray-600">
<div class="flex gap-2 items-center">
<input
type="text"
x-model="newDirName"
@keyup.enter="createDirectory()"
@keyup.escape="showCreateDir = false; newDirName = ''"
placeholder="Directory name"
class="flex-1 px-3 py-2 bg-gray-600 border border-gray-500 text-gray-100 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
x-ref="newDirInput"
>
<button @click="createDirectory()" class="px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded">
Create
</button>
<button @click="showCreateDir = false; newDirName = ''" class="px-4 py-2 border border-gray-500 rounded hover:bg-gray-700 text-gray-200">
Cancel
</button>
</div>
</div>
<div class="flex justify-between items-center mb-2">
<button
x-show="!showCreateDir && currentPath !== 'drives:'"
@click="showCreateDir = true; $nextTick(() => $refs.newDirInput?.focus())"
class="px-3 py-1 text-sm border border-blue-500 rounded hover:bg-blue-900/30 text-blue-400"
>
+ New Folder
</button>
<div class="flex-1"></div>
</div>
<div class="border border-gray-700 rounded max-h-96 overflow-y-auto bg-gray-900">
<template x-for="entry in fileEntries" :key="entry.name">
<div
class="flex items-center justify-between p-3 hover:bg-gray-700 border-b border-gray-700 cursor-pointer text-gray-200"
@click="entry.type === 'directory' ? (fileBrowserForKeyGen ? selectDirectoryForKeyGen(entry.name) : navigateInto(entry.name)) : (!fileBrowserForKeyGen && !fileBrowserForDefaultLocation ? selectFile(entry.name) : null)"
>
<div class="flex items-center gap-2">
<span x-text="entry.type === 'directory' ? '๐' : '๐'"></span>
<span x-text="entry.name"></span>
</div>
<button
x-show="entry.type === 'directory' && fileBrowserForKeyGen"
@click.stop="selectDirectoryForKeyGen(entry.name)"
class="text-green-500 hover:text-green-600 text-sm"
>
Select
</button>
<button
x-show="entry.type === 'file' && !fileBrowserForKeyGen && !fileBrowserForDefaultLocation"
@click.stop="selectFile(entry.name)"
class="text-green-500 hover:text-green-600 text-sm"
>
Select
</button>
</div>
</template>
</div>
</div>
</div>
</div>
<div x-show="showKeyGenerator" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" @click.self="closeKeyGenerator()">
<div class="bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full mx-4">
<div class="flex justify-between items-center p-6 border-b border-gray-700">
<h2 class="text-xl font-bold text-gray-100">Generate SSH Key</h2>
<button @click="closeKeyGenerator()" class="text-gray-400 hover:text-gray-200 text-2xl">×</button>
</div>
<div class="p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-200 mb-2">Name</label>
<input type="text" x-model="keyGenName" class="w-full px-3 py-2 bg-gray-700 border border-gray-600 text-gray-100 rounded focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-200 mb-2">Location</label>
<div class="flex gap-2">
<input type="text" x-model="keyGenLocation" class="flex-1 px-3 py-2 bg-gray-700 border border-gray-600 text-gray-100 rounded focus:outline-none focus:ring-2 focus:ring-blue-500">
<button @click="openFileBrowserForKeyGen()" class="px-4 py-2 border border-gray-500 rounded hover:bg-gray-700 text-gray-200">
Browse
</button>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-200 mb-2">Type</label>
<select x-model="keyGenType" class="w-full px-3 py-2 bg-gray-700 border border-gray-600 text-gray-100 rounded focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="ed25519">Ed25519 (Recommended)</option>
<option value="rsa">RSA 4096</option>
</select>
</div>
<div x-show="generatedPublicKey" class="bg-gray-700 p-3 rounded border-t-2 border-green-500">
<div class="flex justify-between items-center mb-2">
<label class="block text-sm font-medium text-gray-300">Public key (copy to server):</label>
<button @click="copyToClipboard(generatedPublicKey)" class="text-blue-400 hover:text-blue-300 text-sm">Copy</button>
</div>
<pre class="text-xs bg-gray-900 p-2 rounded border border-gray-600 text-gray-100 overflow-x-auto" x-text="generatedPublicKey"></pre>
</div>
</div>
<div class="flex justify-end gap-2 p-6 border-t">
<button @click="closeKeyGenerator()" class="px-4 py-2 border border-gray-500 rounded hover:bg-gray-700 text-gray-200">
Cancel
</button>
<button @click="generateKey()" class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded">
Generate
</button>
</div>
</div>
</div>
<!-- Confirmation Dialog -->
<div x-show="showConfirmDialog" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" @click.self="showConfirmDialog = false">
<div class="bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4 border border-gray-700">
<div class="p-6">
<h3 class="text-xl font-bold text-gray-100 mb-2" x-text="confirmDialogTitle"></h3>
<p class="text-gray-300 mb-6" x-text="confirmDialogMessage"></p>
<div class="flex gap-3 justify-end">
<button @click="showConfirmDialog = false" class="px-4 py-2 border border-gray-500 rounded hover:bg-gray-700 text-gray-200">
Cancel
</button>
<button @click="executeConfirm()" class="px-4 py-2 bg-red-500 hover:bg-red-600 text-white rounded">
Confirm
</button>
</div>
</div>
</div>
</div>
</div>
<script>
function app() {
return {
connections: [],
connectionStatus: {}, // Track connection test status: 'untested', 'testing', 'success', 'failed'
globalRulesExpanded: false,
globalAllowedCommands: [],
globalDeniedCommands: [],
defaultKeyLocation: '~/.ssh',
showConfirmDialog: false,
confirmDialogTitle: '',
confirmDialogMessage: '',
confirmDialogCallback: null,
showModal: false,
showFileBrowser: false,
fileBrowserForKeyGen: false,
fileBrowserForDefaultLocation: false,
showKeyGenerator: false,
modalTitle: '',
activeTab: 'settings',
editingConnection: false,
currentConnection: {},
publicKey: '',
history: [],
historyFilter: '',
testCommand: '',
testResult: null,
currentPath: '',
parentPath: null,
fileEntries: [],
keyGenName: '',
keyGenLocation: '~/.ssh/',
keyGenType: 'ed25519',
generatedPublicKey: '',
showCreateDir: false,
newDirName: '',
async init() {
try {
await this.loadConfig();
// Wait a bit for UI to render before testing
await new Promise(resolve => setTimeout(resolve, 100));
await this.testAllConnections();
} catch (error) {
console.error('Error during initialization:', error);
}
},
async loadConfig() {
try {
const response = await fetch('/api/config');
const config = await response.json();
this.globalAllowedCommands = [...(config.global.allowedCommands || [])];
this.globalDeniedCommands = [...(config.global.disallowedCommands || [])];
this.defaultKeyLocation = config.global.defaultKeyLocation || '~/.ssh';
this.currentPath = this.defaultKeyLocation;
const connsResponse = await fetch('/api/connections');
const connsData = await connsResponse.json();
this.connections = connsData.connections || [];
// Initialize connection statuses as untested
if (!this.connectionStatus) {
this.connectionStatus = {};
}
if (this.connections && Array.isArray(this.connections)) {
this.connections.forEach(conn => {
if (conn && conn.name && !this.connectionStatus[conn.name]) {
this.connectionStatus[conn.name] = 'untested';
}
});
}
} catch (error) {
alert('Error loading configuration: ' + error.message);
}
},
async saveConfig() {
try {
const allowedCommands = (this.globalAllowedCommands || []).filter(cmd => cmd && cmd.trim());
const disallowedCommands = (this.globalDeniedCommands || []).filter(cmd => cmd && cmd.trim());
const defaultKeyLocation = this.defaultKeyLocation || '~/.ssh';
await fetch('/api/config/global', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ allowedCommands, disallowedCommands, defaultKeyLocation })
});
alert('Configuration saved successfully');
await this.loadConfig();
} catch (error) {
alert('Error saving configuration: ' + error.message);
}
},
addGlobalAllowedCommand() {
this.globalAllowedCommands.push('');
},
removeGlobalAllowedCommand(index) {
this.globalAllowedCommands.splice(index, 1);
},
addGlobalDeniedCommand() {
this.globalDeniedCommands.push('');
},
removeGlobalDeniedCommand(index) {
this.globalDeniedCommands.splice(index, 1);
},
openAddConnection() {
this.editingConnection = false;
this.modalTitle = 'Add Connection';
this.currentConnection = {
name: '',
description: '',
host: '',
port: 22,
username: '',
identityFile: '',
allowedCommands: [],
disallowedCommands: [],
active: true
};
this.publicKey = '';
this.activeTab = 'settings';
this.showModal = true;
},
async openEditConnection(conn) {
this.editingConnection = true;
this.modalTitle = `Edit Connection: ${conn.name}`;
this.currentConnection = { ...conn };
this.activeTab = 'settings';
this.showModal = true;
await this.loadPublicKey();
await this.loadHistory();
},
closeModal() {
this.showModal = false;
this.currentConnection = {};
this.history = [];
this.testCommand = '';
this.testResult = null;
},
async saveConnection() {
try {
const method = this.editingConnection ? 'PUT' : 'POST';
const url = this.editingConnection
? `/api/connections/${this.currentConnection.name}`
: '/api/connections';
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.currentConnection)
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error);
}
alert('Connection saved successfully');
this.closeModal();
await this.loadConfig();
} catch (error) {
alert('Error saving connection: ' + error.message);
}
},
confirmDeleteConnection() {
this.confirmDialogTitle = 'Delete Connection';
this.confirmDialogMessage = `Are you sure you want to delete connection '${this.currentConnection.name}'? This action cannot be undone.`;
this.confirmDialogCallback = () => this.deleteConnection();
this.showConfirmDialog = true;
},
async deleteConnection() {
try {
await fetch(`/api/connections/${this.currentConnection.name}`, {
method: 'DELETE'
});
alert('Connection deleted successfully');
this.closeModal();
await this.loadConfig();
} catch (error) {
alert('Error deleting connection: ' + error.message);
}
},
async testConnection(name, silent = false) {
if (!name) {
console.warn('testConnection called without name');
return;
}
try {
// Ensure connectionStatus is initialized
if (!this.connectionStatus) {
this.connectionStatus = {};
}
this.connectionStatus[name] = 'testing';
// Force reactivity update
if (this.$nextTick) {
this.$nextTick();
}
const response = await fetch(`/api/connections/${name}/test`, {
method: 'POST'
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result && result.success) {
this.connectionStatus[name] = 'success';
if (!silent) {
alert('Connection successful!');
}
} else {
this.connectionStatus[name] = 'failed';
if (!silent) {
alert(`Connection failed: ${result?.error || 'Unknown error'}`);
}
}
// Force reactivity update
if (this.$nextTick) {
this.$nextTick();
}
} catch (error) {
console.error(`Error testing connection ${name}:`, error);
if (!this.connectionStatus) {
this.connectionStatus = {};
}
this.connectionStatus[name] = 'failed';
// Force reactivity update
if (this.$nextTick) {
this.$nextTick();
}
if (!silent) {
alert('Error testing connection: ' + (error.message || error));
}
}
},
async testAllConnections() {
try {
// Test all active connections in parallel
if (!this.connections || this.connections.length === 0) {
return;
}
const activeConnections = this.connections.filter(conn => conn && conn.name && conn.active !== false);
if (activeConnections.length === 0) {
return;
}
const testPromises = activeConnections.map(conn => {
if (conn && conn.name) {
return this.testConnection(conn.name, true).catch(err => {
console.error(`Error testing connection ${conn.name}:`, err);
return null;
});
}
return null;
}).filter(Boolean);
await Promise.all(testPromises);
} catch (error) {
console.error('Error in testAllConnections:', error);
}
},
getToggleColor(conn) {
try {
if (!conn || !conn.name) {
return 'bg-gray-600 peer-checked:bg-gray-500';
}
if (!this.connectionStatus) {
this.connectionStatus = {};
}
const status = this.connectionStatus[conn.name] || 'untested';
if (status === 'success') {
return 'bg-gray-600 peer-checked:bg-green-500 peer-focus:ring-green-800';
} else if (status === 'failed') {
return 'bg-gray-600 peer-checked:bg-red-500 peer-focus:ring-red-800';
} else {
// untested or testing
return 'bg-gray-600 peer-checked:bg-orange-500 peer-focus:ring-orange-800';
}
} catch (error) {
console.error('Error in getToggleColor:', error);
return 'bg-gray-600 peer-checked:bg-gray-500';
}
},
async testConnectionInModal() {
await this.testConnection(this.currentConnection.name);
},
async testCommandValidation() {
if (!this.testCommand || !this.currentConnection.name) {
return;
}
try {
const response = await fetch(`/api/connections/${this.currentConnection.name}/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: this.testCommand })
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || 'Validation failed');
}
this.testResult = result;
} catch (error) {
alert('Error testing command: ' + error.message);
this.testResult = null;
}
},
async loadPublicKey() {
if (!this.currentConnection.identityFile) return;
try {
const response = await fetch(`/api/keys/public?path=${encodeURIComponent(this.currentConnection.identityFile)}`);
const data = await response.json();
if (data.exists) {
this.publicKey = data.publicKey;
} else {
this.publicKey = '';
}
} catch (error) {
this.publicKey = '';
}
},
async loadHistory() {
if (!this.currentConnection.name) return;
try {
const response = await fetch(`/api/history/${this.currentConnection.name}?limit=100`);
const data = await response.json();
this.history = data.entries || [];
} catch (error) {
this.history = [];
}
},
confirmClearHistory() {
this.confirmDialogTitle = 'Clear History';
this.confirmDialogMessage = `Are you sure you want to clear the history for connection '${this.currentConnection.name}'? This action cannot be undone.`;
this.confirmDialogCallback = () => this.clearConnectionHistory();
this.showConfirmDialog = true;
},
async clearConnectionHistory() {
try {
await fetch(`/api/history/${this.currentConnection.name}`, {
method: 'DELETE'
});
alert('History cleared successfully');
await this.loadHistory();
} catch (error) {
alert('Error clearing history: ' + error.message);
}
},
executeConfirm() {
this.showConfirmDialog = false;
if (this.confirmDialogCallback) {
this.confirmDialogCallback();
this.confirmDialogCallback = null;
}
},
get filteredHistory() {
if (!this.historyFilter) return this.history;
const filter = this.historyFilter.toLowerCase();
return this.history.filter(entry =>
entry.command.toLowerCase().includes(filter)
);
},
async openFileBrowser() {
this.fileBrowserForKeyGen = false;
this.fileBrowserForDefaultLocation = false;
const defaultLocation = this.defaultKeyLocation || '~/.ssh';
this.currentPath = defaultLocation;
await this.loadFiles();
this.showFileBrowser = true;
},
async openFileBrowserForKeyGen() {
this.fileBrowserForKeyGen = true;
this.fileBrowserForDefaultLocation = false;
// Use the current keyGenLocation or default
let startPath = this.keyGenLocation || this.defaultKeyLocation || '~/.ssh';
// Remove trailing slash and filename if present
startPath = startPath.replace(/\/[^/]*$/, '');
if (!startPath) {
startPath = this.defaultKeyLocation || '~/.ssh';
}
this.currentPath = startPath;
await this.loadFiles();
this.showFileBrowser = true;
},
async openFileBrowserForDefaultLocation() {
this.fileBrowserForKeyGen = false;
this.fileBrowserForDefaultLocation = true;
let startPath = this.defaultKeyLocation || '~/.ssh';
// Remove trailing slash if present
startPath = startPath.replace(/\/$/, '');
if (!startPath) {
startPath = '~/.ssh';
}
this.currentPath = startPath;
await this.loadFiles();
this.showFileBrowser = true;
},
closeFileBrowser() {
this.showFileBrowser = false;
this.fileBrowserForKeyGen = false;
this.fileBrowserForDefaultLocation = false;
this.showCreateDir = false;
this.newDirName = '';
},
async loadFiles() {
try {
const response = await fetch(`/api/files?path=${encodeURIComponent(this.currentPath)}`);
const data = await response.json();
this.currentPath = data.current;
this.fileEntries = data.entries;
this.parentPath = data.parent;
} catch (error) {
console.error('Error loading files:', error);
alert('Error loading files: ' + error.message);
}
},
async navigateUp() {
if (this.parentPath) {
this.currentPath = this.parentPath;
await this.loadFiles();
}
},
async navigateInto(name) {
// Trim any whitespace
name = name.trim();
// Check if clicking on a drive (name like "S:\" or "S:")
// Drive names from the API are like "S:\"
const isDrive = /^[A-Za-z]:\\?$/i.test(name);
if (isDrive) {
// Clicking on a drive from drive list - navigate to it
// Ensure it ends with backslash
this.currentPath = name.endsWith('\\') ? name : name + '\\';
} else {
// Normal navigation
const separator = this.currentPath.includes('\\') ? '\\' : '/';
this.currentPath = this.currentPath + (this.currentPath.endsWith(separator) ? '' : separator) + name;
}
await this.loadFiles();
},
async createDirectory() {
if (!this.newDirName || !this.newDirName.trim()) {
alert('Please enter a directory name');
return;
}
if (this.currentPath === 'drives:') {
alert('Cannot create directory in drives view');
return;
}
try {
const response = await fetch('/api/files/mkdir', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
path: this.currentPath,
name: this.newDirName.trim()
})
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to create directory');
}
// Refresh the file list
await this.loadFiles();
// Reset the form
this.showCreateDir = false;
this.newDirName = '';
} catch (error) {
alert('Error creating directory: ' + error.message);
}
},
async selectFile(name) {
this.currentConnection.identityFile = this.currentPath + '/' + name;
this.closeFileBrowser();
await this.loadPublicKey();
},
selectDirectoryForKeyGen(name) {
// Handle Windows paths and drive roots
let selectedPath;
if (this.currentPath.match(/^[A-Z]:\\?$/i) || name.match(/^[A-Z]:\\?$/i)) {
// At drive root or selecting a drive
selectedPath = name.replace(/\\$/, '') + '\\';
} else {
const separator = this.currentPath.includes('\\') ? '\\' : '/';
selectedPath = this.currentPath + (this.currentPath.endsWith(separator) ? '' : separator) + name;
}
this.keyGenLocation = selectedPath.endsWith('/') || selectedPath.endsWith('\\') ? selectedPath : selectedPath + (selectedPath.includes('\\') ? '\\' : '/');
this.closeFileBrowser();
},
selectCurrentDirectoryForKeyGen() {
this.keyGenLocation = this.currentPath.endsWith('/') ? this.currentPath : this.currentPath + '/';
this.closeFileBrowser();
},
selectDirectoryForDefaultLocation(name) {
// Handle Windows paths and drive roots
let selectedPath;
if (this.currentPath.match(/^[A-Z]:\\?$/i) || name.match(/^[A-Z]:\\?$/i)) {
// At drive root or selecting a drive
selectedPath = name.replace(/\\$/, '') + '\\';
} else {
const separator = this.currentPath.includes('\\') ? '\\' : '/';
selectedPath = this.currentPath + (this.currentPath.endsWith(separator) ? '' : separator) + name;
}
this.defaultKeyLocation = selectedPath;
this.closeFileBrowser();
},
selectCurrentDirectory() {
if (this.fileBrowserForKeyGen) {
this.keyGenLocation = this.currentPath.endsWith('/') ? this.currentPath : this.currentPath + '/';
} else if (this.fileBrowserForDefaultLocation) {
this.defaultKeyLocation = this.currentPath;
}
this.closeFileBrowser();
},
openKeyGenerator() {