Initial commit: Q3A RCON Dashboard
This commit is contained in:
commit
75c70967cf
|
|
@ -0,0 +1,19 @@
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
out/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Electron
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# Config with sensitive data
|
||||||
|
servers.json
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
# Q3A RCON Dashboard
|
||||||
|
|
||||||
|
A desktop application for administering Quake 3 Arena servers via RCON protocol. Built with Electron.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Multi-server management with tabbed interface
|
||||||
|
- Quick-action buttons for common commands (Status, Kick, Say, Map, Ban, etc.)
|
||||||
|
- Three switchable themes: Terminal, Dark, Light
|
||||||
|
- Server configurations persisted to JSON
|
||||||
|
- Auto-open saved servers on startup
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- npm 9+
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
### Linux (AppImage)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:linux
|
||||||
|
```
|
||||||
|
|
||||||
|
Output: `dist/Q3A RCON Dashboard-1.0.0.AppImage`
|
||||||
|
|
||||||
|
### Windows (Portable EXE)
|
||||||
|
|
||||||
|
On a Windows machine:
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
npm run build:win
|
||||||
|
```
|
||||||
|
|
||||||
|
Output: `dist/Q3A RCON Dashboard-1.0.0.exe`
|
||||||
|
|
||||||
|
### Build Both
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## RCON Commands
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `status` | Player list with scores and pings |
|
||||||
|
| `serverinfo` | Server settings (gametype, fraglimit, etc.) |
|
||||||
|
| `systeminfo` | System info (OS, CPU) |
|
||||||
|
| `kick <name>` | Kick player (or "all", "allbots") |
|
||||||
|
| `kicknum <num>` | Kick by slot number |
|
||||||
|
| `say <msg>` | Broadcast to all players |
|
||||||
|
| `map <name>` | Change map (e.g., q3dm1) |
|
||||||
|
| `banaddr <ip>` | Ban IP address |
|
||||||
|
| `listbans` | List all bans |
|
||||||
|
| `rehashbans` | Reload bans from file |
|
||||||
|
| `flushbans` | Delete all bans |
|
||||||
|
|
||||||
|
See the in-app Reference button for full command list.
|
||||||
|
|
||||||
|
## Tested Servers
|
||||||
|
|
||||||
|
- ioquake3
|
||||||
|
- Q3ALight
|
||||||
|
- Other Q3A-compatible engines
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Q3A RCON Dashboard</title>
|
||||||
|
<link rel="stylesheet" href="styles/terminal.css" id="theme-stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app">
|
||||||
|
<header class="toolbar">
|
||||||
|
<div class="toolbar-left">
|
||||||
|
<button id="btn-add-server" class="toolbar-btn">+ Add Server</button>
|
||||||
|
<button id="btn-servers" class="toolbar-btn">Servers</button>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-center">
|
||||||
|
<button class="theme-btn" data-theme="terminal">Terminal</button>
|
||||||
|
<button class="theme-btn" data-theme="dark">Dark</button>
|
||||||
|
<button class="theme-btn" data-theme="light">Light</button>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-right">
|
||||||
|
<button class="toolbar-btn" id="btn-reference">Reference</button>
|
||||||
|
<button class="toolbar-btn" id="btn-about">About</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="main-content">
|
||||||
|
<div class="tabs-bar" id="tabs-bar">
|
||||||
|
<span class="tabs-placeholder" id="tabs-placeholder">No servers configured. Click "+ Add Server" to begin.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="server-content" id="server-content">
|
||||||
|
<div class="welcome-message">
|
||||||
|
<h1>Q3A RCON Dashboard</h1>
|
||||||
|
<p>Select or add a server to begin.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="status-bar" id="status-bar">
|
||||||
|
<span>Ready</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal" id="modal-add-server">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2>Add / Edit Server</h2>
|
||||||
|
<form id="form-server">
|
||||||
|
<input type="hidden" id="server-id">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="server-name">Name</label>
|
||||||
|
<input type="text" id="server-name" placeholder="My Server" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="server-address">Address</label>
|
||||||
|
<input type="text" id="server-address" placeholder="192.168.1.100" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="server-port">Port</label>
|
||||||
|
<input type="number" id="server-port" value="27960" min="1" max="65535" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="server-password">RCON Password</label>
|
||||||
|
<input type="password" id="server-password" placeholder="********" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn-cancel" id="btn-cancel-server">Cancel</button>
|
||||||
|
<button type="submit" class="btn-save">Save</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal" id="modal-servers">
|
||||||
|
<div class="modal-content modal-large">
|
||||||
|
<h2>Manage Servers</h2>
|
||||||
|
<div id="server-list" class="server-list"></div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn-cancel" id="btn-close-servers">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal" id="modal-about">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2>About Q3A RCON Dashboard</h2>
|
||||||
|
<p>Version 1.0.0</p>
|
||||||
|
<p>A minimalist administration tool for Quake 3 Arena servers.</p>
|
||||||
|
<p>Supports: ioquake3, Q3ALight, and compatible engines.</p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn-cancel" id="btn-close-about">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal" id="modal-reference">
|
||||||
|
<div class="modal-content" style="max-width:600px;max-height:80vh;overflow-y:auto;">
|
||||||
|
<h2>Q3A RCON Command Reference</h2>
|
||||||
|
<h3>Server Management</h3>
|
||||||
|
<table class="ref-table">
|
||||||
|
<tr><td><code>map <mapname></code></td><td>Change map (e.g., q3dm1)</td></tr>
|
||||||
|
<tr><td><code>devmap <mapname></code></td><td>Change map with cheats enabled</td></tr>
|
||||||
|
<tr><td><code>map_restart <sec></code></td><td>Restart map (default 5 sec)</td></tr>
|
||||||
|
<tr><td><code>killserver</code></td><td>Shutdown the server</td></tr>
|
||||||
|
</table>
|
||||||
|
<h3>Player Management</h3>
|
||||||
|
<table class="ref-table">
|
||||||
|
<tr><td><code>kick <name></code></td><td>Kick player by name (or "all", "allbots")</td></tr>
|
||||||
|
<tr><td><code>kicknum <num></code></td><td>Kick by slot number</td></tr>
|
||||||
|
<tr><td><code>kickall</code></td><td>Kick all players</td></tr>
|
||||||
|
<tr><td><code>kickbots</code></td><td>Kick all bots</td></tr>
|
||||||
|
</table>
|
||||||
|
<h3>Banning</h3>
|
||||||
|
<table class="ref-table">
|
||||||
|
<tr><td><code>banaddr <ip[/subnet]></code></td><td>Ban IP or CIDR range</td></tr>
|
||||||
|
<tr><td><code>exceptaddr <ip[/subnet]></code></td><td>Add ban exception</td></tr>
|
||||||
|
<tr><td><code>bandel <range|num></code></td><td>Delete ban by range or number</td></tr>
|
||||||
|
<tr><td><code>exceptdel <range|num></code></td><td>Delete exception</td></tr>
|
||||||
|
<tr><td><code>listbans</code></td><td>List all bans</td></tr>
|
||||||
|
<tr><td><code>rehashbans</code></td><td>Reload bans from file</td></tr>
|
||||||
|
<tr><td><code>flushbans</code></td><td>Delete all bans</td></tr>
|
||||||
|
</table>
|
||||||
|
<h3>Information</h3>
|
||||||
|
<table class="ref-table">
|
||||||
|
<tr><td><code>status</code></td><td>Player list with scores and pings</td></tr>
|
||||||
|
<tr><td><code>serverinfo</code></td><td>Server settings (gametype, fraglimit, etc.)</td></tr>
|
||||||
|
<tr><td><code>systeminfo</code></td><td>System info (OS, CPU)</td></tr>
|
||||||
|
<tr><td><code>dumpuser <name></code></td><td>Player userinfo</td></tr>
|
||||||
|
</table>
|
||||||
|
<h3>Messaging</h3>
|
||||||
|
<table class="ref-table">
|
||||||
|
<tr><td><code>say <message></code></td><td>Broadcast to all players</td></tr>
|
||||||
|
<tr><td><code>tell <num> <msg></code></td><td>Message single player</td></tr>
|
||||||
|
<tr><td><code>sayto <name> <text></code></td><td>Message player by name</td></tr>
|
||||||
|
</table>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn-cancel" id="btn-close-reference">Close</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal" id="modal-input">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2 id="modal-input-title">Enter Value</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<input type="text" id="modal-input-field" placeholder="">
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn-cancel" id="btn-cancel-input">Cancel</button>
|
||||||
|
<button type="button" class="btn-save" id="btn-submit-input">OK</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="js/renderer.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,420 @@
|
||||||
|
const MAX_HISTORY = 50;
|
||||||
|
|
||||||
|
let servers = [];
|
||||||
|
let activeTabs = {};
|
||||||
|
let tabCounter = 0;
|
||||||
|
let currentTheme = 'terminal';
|
||||||
|
|
||||||
|
const themes = {
|
||||||
|
terminal: 'styles/terminal.css',
|
||||||
|
dark: 'styles/dark.css',
|
||||||
|
light: 'styles/light.css'
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
servers = await window.rcon.getServers();
|
||||||
|
setupEventListeners();
|
||||||
|
setupThemeButtons();
|
||||||
|
renderServerList();
|
||||||
|
servers.forEach(s => openServer(s.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
let inputModalCallback = null;
|
||||||
|
|
||||||
|
function setupEventListeners() {
|
||||||
|
document.getElementById('btn-add-server').addEventListener('click', showAddServerModal);
|
||||||
|
document.getElementById('btn-servers').addEventListener('click', showServersModal);
|
||||||
|
document.getElementById('btn-cancel-server').addEventListener('click', hideAddServerModal);
|
||||||
|
document.getElementById('btn-close-servers').addEventListener('click', hideServersModal);
|
||||||
|
document.getElementById('btn-close-about').addEventListener('click', hideAboutModal);
|
||||||
|
document.getElementById('btn-about').addEventListener('click', showAboutModal);
|
||||||
|
document.getElementById('btn-close-reference').addEventListener('click', hideReferenceModal);
|
||||||
|
document.getElementById('btn-reference').addEventListener('click', showReferenceModal);
|
||||||
|
document.getElementById('form-server').addEventListener('submit', saveServer);
|
||||||
|
document.getElementById('btn-cancel-input').addEventListener('click', hideInputModal);
|
||||||
|
document.getElementById('btn-submit-input').addEventListener('click', submitInputModal);
|
||||||
|
document.getElementById('modal-input-field').addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') submitInputModal();
|
||||||
|
if (e.key === 'Escape') hideInputModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupThemeButtons() {
|
||||||
|
document.querySelectorAll('.theme-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
setTheme(btn.dataset.theme);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
setTheme(currentTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTheme(theme) {
|
||||||
|
currentTheme = theme;
|
||||||
|
document.getElementById('theme-stylesheet').href = themes[theme];
|
||||||
|
document.querySelectorAll('.theme-btn').forEach(btn => {
|
||||||
|
btn.classList.toggle('active', btn.dataset.theme === theme);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAddServerModal(server = null) {
|
||||||
|
const modal = document.getElementById('modal-add-server');
|
||||||
|
document.getElementById('server-id').value = server?.id || '';
|
||||||
|
document.getElementById('server-name').value = server?.name || '';
|
||||||
|
document.getElementById('server-address').value = server?.address || '';
|
||||||
|
document.getElementById('server-port').value = server?.port || 27960;
|
||||||
|
document.getElementById('server-password').value = server?.password || '';
|
||||||
|
modal.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideAddServerModal() {
|
||||||
|
document.getElementById('modal-add-server').classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showServersModal() {
|
||||||
|
renderServerList();
|
||||||
|
document.getElementById('modal-servers').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideServersModal() {
|
||||||
|
document.getElementById('modal-servers').classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAboutModal() {
|
||||||
|
document.getElementById('modal-about').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideAboutModal() {
|
||||||
|
document.getElementById('modal-about').classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showReferenceModal() {
|
||||||
|
document.getElementById('modal-reference').classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideReferenceModal() {
|
||||||
|
document.getElementById('modal-reference').classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showInputModal(title, placeholder, callback) {
|
||||||
|
inputModalCallback = callback;
|
||||||
|
document.getElementById('modal-input-title').textContent = title;
|
||||||
|
document.getElementById('modal-input-field').placeholder = placeholder;
|
||||||
|
document.getElementById('modal-input-field').value = '';
|
||||||
|
document.getElementById('modal-input').classList.add('active');
|
||||||
|
document.getElementById('modal-input-field').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideInputModal() {
|
||||||
|
document.getElementById('modal-input').classList.remove('active');
|
||||||
|
inputModalCallback = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitInputModal() {
|
||||||
|
const value = document.getElementById('modal-input-field').value;
|
||||||
|
document.getElementById('modal-input').classList.remove('active');
|
||||||
|
if (inputModalCallback) {
|
||||||
|
const cb = inputModalCallback;
|
||||||
|
inputModalCallback = null;
|
||||||
|
cb(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveServer(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const server = {
|
||||||
|
id: document.getElementById('server-id').value || undefined,
|
||||||
|
name: document.getElementById('server-name').value,
|
||||||
|
address: document.getElementById('server-address').value,
|
||||||
|
port: parseInt(document.getElementById('server-port').value),
|
||||||
|
password: document.getElementById('server-password').value
|
||||||
|
};
|
||||||
|
servers = await window.rcon.saveServer(server);
|
||||||
|
hideAddServerModal();
|
||||||
|
renderServerList();
|
||||||
|
const saved = servers.find(s => s.name === server.name && s.address === server.address);
|
||||||
|
if (saved && !activeTabs[saved.id]) {
|
||||||
|
openServer(saved.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteServer(id) {
|
||||||
|
if (confirm('Delete this server?')) {
|
||||||
|
servers = await window.rcon.deleteServer(id);
|
||||||
|
closeTab(id);
|
||||||
|
renderServerList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderServerList() {
|
||||||
|
const list = document.getElementById('server-list');
|
||||||
|
if (servers.length === 0) {
|
||||||
|
list.innerHTML = '<p class="no-servers">No servers configured.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = servers.map(s => `
|
||||||
|
<div class="server-item">
|
||||||
|
<div class="server-info">
|
||||||
|
<strong>${escapeHtml(s.name)}</strong>
|
||||||
|
<span>${escapeHtml(s.address)}:${s.port}</span>
|
||||||
|
</div>
|
||||||
|
<div class="server-actions">
|
||||||
|
<button onclick="openServer('${s.id}')">Open</button>
|
||||||
|
<button onclick="showAddServerModal(servers.find(x => x.id === '${s.id}'))">Edit</button>
|
||||||
|
<button onclick="deleteServer('${s.id}')" class="btn-danger">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openServer(id) {
|
||||||
|
const server = servers.find(s => s.id === id);
|
||||||
|
if (!server) return;
|
||||||
|
|
||||||
|
if (!activeTabs[id]) {
|
||||||
|
activeTabs[id] = createTab(server);
|
||||||
|
tabCounter++;
|
||||||
|
}
|
||||||
|
switchToTab(id);
|
||||||
|
hideServersModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTab(server) {
|
||||||
|
const id = server.id;
|
||||||
|
const tab = {
|
||||||
|
id,
|
||||||
|
name: server.name,
|
||||||
|
server,
|
||||||
|
history: [],
|
||||||
|
historyIndex: -1
|
||||||
|
};
|
||||||
|
|
||||||
|
const tabEl = document.createElement('div');
|
||||||
|
tabEl.className = 'tab';
|
||||||
|
tabEl.id = `tab-${id}`;
|
||||||
|
tabEl.innerHTML = `
|
||||||
|
<span class="tab-name">${escapeHtml(server.name)}</span>
|
||||||
|
<span class="tab-close" data-id="${id}">×</span>
|
||||||
|
`;
|
||||||
|
tabEl.addEventListener('click', () => switchToTab(id));
|
||||||
|
|
||||||
|
const contentEl = document.createElement('div');
|
||||||
|
contentEl.className = 'tab-content';
|
||||||
|
contentEl.id = `content-${id}`;
|
||||||
|
contentEl.innerHTML = `
|
||||||
|
<div class="server-header">
|
||||||
|
<h2>${escapeHtml(server.name)}</h2>
|
||||||
|
<span class="server-address">${escapeHtml(server.address)}:${server.port}</span>
|
||||||
|
</div>
|
||||||
|
<div class="quick-actions">
|
||||||
|
<span class="qa-buttons">
|
||||||
|
<button class="qa-btn" data-cmd="status">Status</button>
|
||||||
|
<button class="qa-btn" data-cmd="serverinfo">Info</button>
|
||||||
|
<button class="qa-btn" data-cmd="systeminfo">Sys Info</button>
|
||||||
|
<button class="qa-btn" data-cmd="kick">Kick</button>
|
||||||
|
<button class="qa-btn" data-cmd="say">Say</button>
|
||||||
|
<button class="qa-btn" data-cmd="map">Map</button>
|
||||||
|
<button class="qa-btn" data-cmd="banaddr">Ban</button>
|
||||||
|
</span>
|
||||||
|
<button class="qa-btn qa-clear" data-id="${id}">Clear</button>
|
||||||
|
</div>
|
||||||
|
<div class="output" id="output-${id}"></div>
|
||||||
|
<div class="command-area">
|
||||||
|
<input type="text" class="cmd-input" id="cmd-${id}" placeholder="Enter command...">
|
||||||
|
<button class="cmd-send" data-id="${id}">Send</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('tabs-bar').appendChild(tabEl);
|
||||||
|
document.getElementById('server-content').appendChild(contentEl);
|
||||||
|
|
||||||
|
const placeholder = document.getElementById('tabs-placeholder');
|
||||||
|
if (placeholder) placeholder.style.display = 'none';
|
||||||
|
|
||||||
|
tabEl.querySelector('.tab-close').addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
closeTab(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
contentEl.querySelector('.cmd-input').addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
sendCommand(id);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
navigateHistory(id, -1);
|
||||||
|
} else if (e.key === 'ArrowDown') {
|
||||||
|
navigateHistory(id, 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
contentEl.querySelector('.cmd-send').addEventListener('click', () => sendCommand(id));
|
||||||
|
|
||||||
|
contentEl.querySelectorAll('.qa-btn[data-cmd]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => handleQuickAction(id, btn.dataset.cmd));
|
||||||
|
});
|
||||||
|
|
||||||
|
contentEl.querySelector('.qa-clear').addEventListener('click', () => {
|
||||||
|
const output = document.getElementById(`output-${id}`);
|
||||||
|
if (output) output.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
return tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchToTab(id) {
|
||||||
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||||
|
|
||||||
|
const tabEl = document.getElementById(`tab-${id}`);
|
||||||
|
const contentEl = document.getElementById(`content-${id}`);
|
||||||
|
|
||||||
|
if (tabEl && contentEl) {
|
||||||
|
tabEl.classList.add('active');
|
||||||
|
contentEl.classList.add('active');
|
||||||
|
contentEl.querySelector('.cmd-input')?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTab(id) {
|
||||||
|
const tabEl = document.getElementById(`tab-${id}`);
|
||||||
|
const contentEl = document.getElementById(`content-${id}`);
|
||||||
|
|
||||||
|
if (tabEl) tabEl.remove();
|
||||||
|
if (contentEl) contentEl.remove();
|
||||||
|
|
||||||
|
delete activeTabs[id];
|
||||||
|
|
||||||
|
const remaining = Object.keys(activeTabs);
|
||||||
|
if (remaining.length > 0) {
|
||||||
|
switchToTab(remaining[remaining.length - 1]);
|
||||||
|
} else {
|
||||||
|
document.getElementById('server-content').innerHTML = `
|
||||||
|
<div class="welcome-message">
|
||||||
|
<h1>Q3A RCON Dashboard</h1>
|
||||||
|
<p>Select or add a server to begin.</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
const placeholder = document.getElementById('tabs-placeholder');
|
||||||
|
if (placeholder) placeholder.style.display = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendCommand(tabId) {
|
||||||
|
const input = document.getElementById(`cmd-${tabId}`);
|
||||||
|
const output = document.getElementById(`output-${tabId}`);
|
||||||
|
const command = input.value.trim();
|
||||||
|
|
||||||
|
if (!command || !activeTabs[tabId]) return;
|
||||||
|
|
||||||
|
const tab = activeTabs[tabId];
|
||||||
|
tab.history.push(command);
|
||||||
|
if (tab.history.length > MAX_HISTORY) tab.history.shift();
|
||||||
|
tab.historyIndex = tab.history.length;
|
||||||
|
|
||||||
|
input.value = '';
|
||||||
|
appendOutput(tabId, `> ${command}`, 'command');
|
||||||
|
|
||||||
|
const result = await window.rcon.send({
|
||||||
|
address: tab.server.address,
|
||||||
|
port: tab.server.port,
|
||||||
|
password: tab.server.password,
|
||||||
|
command
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
appendOutput(tabId, result.response || '(no response)', 'response');
|
||||||
|
} else {
|
||||||
|
appendOutput(tabId, `Error: ${result.error}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleQuickAction(tabId, action) {
|
||||||
|
const tab = activeTabs[tabId];
|
||||||
|
if (!tab) return;
|
||||||
|
|
||||||
|
let command = action;
|
||||||
|
let promptTitle = null;
|
||||||
|
let promptPlaceholder = null;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'kick':
|
||||||
|
promptTitle = 'Kick Player';
|
||||||
|
promptPlaceholder = 'Player name or slot number';
|
||||||
|
command = 'kick ';
|
||||||
|
break;
|
||||||
|
case 'say':
|
||||||
|
promptTitle = 'Broadcast Message';
|
||||||
|
promptPlaceholder = 'Message to send to all players';
|
||||||
|
command = 'say ';
|
||||||
|
break;
|
||||||
|
case 'map':
|
||||||
|
promptTitle = 'Change Map';
|
||||||
|
promptPlaceholder = 'Map name (e.g., q3dm1)';
|
||||||
|
command = 'map ';
|
||||||
|
break;
|
||||||
|
case 'banaddr':
|
||||||
|
promptTitle = 'Ban IP Address';
|
||||||
|
promptPlaceholder = 'IP or IP/subnet (e.g., 192.168.1.100)';
|
||||||
|
command = 'banaddr ';
|
||||||
|
break;
|
||||||
|
case 'dumpuser':
|
||||||
|
promptTitle = 'Dump User Info';
|
||||||
|
promptPlaceholder = 'Player name or slot number';
|
||||||
|
command = 'dumpuser ';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (promptTitle) {
|
||||||
|
showInputModal(promptTitle, promptPlaceholder, (value) => {
|
||||||
|
if (!value) return;
|
||||||
|
const fullCommand = command + value;
|
||||||
|
document.getElementById(`cmd-${tabId}`).value = fullCommand;
|
||||||
|
sendCommand(tabId);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById(`cmd-${tabId}`).value = command;
|
||||||
|
await sendCommand(tabId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateHistory(tabId, direction) {
|
||||||
|
const tab = activeTabs[tabId];
|
||||||
|
if (!tab || tab.history.length === 0) return;
|
||||||
|
|
||||||
|
tab.historyIndex += direction;
|
||||||
|
if (tab.historyIndex < 0) tab.historyIndex = 0;
|
||||||
|
if (tab.historyIndex >= tab.history.length) {
|
||||||
|
tab.historyIndex = tab.history.length;
|
||||||
|
document.getElementById(`cmd-${tabId}`).value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById(`cmd-${tabId}`).value = tab.history[tab.historyIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendOutput(tabId, text, type = '') {
|
||||||
|
const output = document.getElementById(`output-${tabId}`);
|
||||||
|
if (!output) return;
|
||||||
|
|
||||||
|
const lines = text.split('\n');
|
||||||
|
lines.forEach(line => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = `output-line ${type}`;
|
||||||
|
div.textContent = line;
|
||||||
|
output.appendChild(div);
|
||||||
|
});
|
||||||
|
|
||||||
|
output.scrollTop = output.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = str;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.servers = servers;
|
||||||
|
window.openServer = openServer;
|
||||||
|
window.deleteServer = deleteServer;
|
||||||
|
window.showAddServerModal = showAddServerModal;
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
const { app, BrowserWindow, ipcMain } = require('electron');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const dgram = require('dgram');
|
||||||
|
|
||||||
|
let mainWindow;
|
||||||
|
let servers = [];
|
||||||
|
|
||||||
|
const configPath = path.join(app.getPath('userData'), 'servers.json');
|
||||||
|
|
||||||
|
function loadConfig() {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(configPath)) {
|
||||||
|
const data = fs.readFileSync(configPath, 'utf8');
|
||||||
|
servers = JSON.parse(data).servers || [];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
servers = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveConfig() {
|
||||||
|
fs.writeFileSync(configPath, JSON.stringify({ servers }, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: 1200,
|
||||||
|
height: 800,
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
mainWindow.loadFile('index.html');
|
||||||
|
}
|
||||||
|
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
loadConfig();
|
||||||
|
createWindow();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('get-servers', () => servers);
|
||||||
|
|
||||||
|
ipcMain.handle('save-server', (event, server) => {
|
||||||
|
const index = servers.findIndex(s => s.id === server.id);
|
||||||
|
if (index >= 0) {
|
||||||
|
servers[index] = server;
|
||||||
|
} else {
|
||||||
|
server.id = Date.now().toString();
|
||||||
|
servers.push(server);
|
||||||
|
}
|
||||||
|
saveConfig();
|
||||||
|
return servers;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('delete-server', (event, id) => {
|
||||||
|
servers = servers.filter(s => s.id !== id);
|
||||||
|
saveConfig();
|
||||||
|
return servers;
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('rcon-send', async (event, { address, port, password, command }) => {
|
||||||
|
const msgStr = 'rcon ' + password + ' ' + command + '\n';
|
||||||
|
const packet = Buffer.alloc(4 + Buffer.byteLength(msgStr));
|
||||||
|
packet.writeUInt32LE(0xFFFFFFFF, 0);
|
||||||
|
packet.write(msgStr, 4);
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const socket = dgram.createSocket('udp4');
|
||||||
|
let responseBuffer = '';
|
||||||
|
let done = false;
|
||||||
|
|
||||||
|
const finish = (success, response) => {
|
||||||
|
if (!done) {
|
||||||
|
done = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
socket.removeAllListeners();
|
||||||
|
try { socket.close(); } catch (e) {}
|
||||||
|
resolve({ success, response: success ? response : null, error: success ? null : response });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
finish(false, 'Timeout waiting for response');
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
socket.on('message', (msg) => {
|
||||||
|
const buf = Buffer.from(msg);
|
||||||
|
let start = 0;
|
||||||
|
if (buf.length >= 4 && buf[0] === 0xFF && buf[1] === 0xFF && buf[2] === 0xFF && buf[3] === 0xFF) {
|
||||||
|
start = 4;
|
||||||
|
}
|
||||||
|
let data = buf.slice(start).toString('ascii');
|
||||||
|
if (data.startsWith('print')) {
|
||||||
|
data = data.slice(5);
|
||||||
|
if (data.charCodeAt(0) === 10) data = data.slice(1);
|
||||||
|
}
|
||||||
|
responseBuffer += data;
|
||||||
|
if (responseBuffer.length > 0) {
|
||||||
|
finish(true, responseBuffer.trim());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', (err) => {
|
||||||
|
finish(false, err.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('close', () => {
|
||||||
|
if (!done) {
|
||||||
|
finish(true, responseBuffer.trim());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.send(packet, 0, packet.length, port, address, (err) => {
|
||||||
|
if (err) {
|
||||||
|
finish(false, err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,33 @@
|
||||||
|
{
|
||||||
|
"name": "q3a-admin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Quake 3 Arena RCON Dashboard",
|
||||||
|
"main": "main.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "electron .",
|
||||||
|
"dev": "electron . --enable-logging",
|
||||||
|
"build:linux": "electron-builder --linux AppImage",
|
||||||
|
"build:win": "electron-builder --win portable",
|
||||||
|
"build": "electron-builder --linux AppImage --win portable"
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"electron": "^28.0.0",
|
||||||
|
"electron-builder": "^24.0.0"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"appId": "com.q3a.admin",
|
||||||
|
"productName": "Q3A RCON Dashboard",
|
||||||
|
"icon": "quake3logo.png",
|
||||||
|
"linux": {
|
||||||
|
"target": "AppImage"
|
||||||
|
},
|
||||||
|
"win": {
|
||||||
|
"target": "portable"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"**/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
|
|
||||||
|
contextBridge.exposeInMainWorld('rcon', {
|
||||||
|
getServers: () => ipcRenderer.invoke('get-servers'),
|
||||||
|
saveServer: (server) => ipcRenderer.invoke('save-server', server),
|
||||||
|
deleteServer: (id) => ipcRenderer.invoke('delete-server', id),
|
||||||
|
send: (data) => ipcRenderer.invoke('rcon-send', data)
|
||||||
|
});
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
|
|
@ -0,0 +1,454 @@
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-primary: #1a1a1e;
|
||||||
|
--bg-secondary: #222228;
|
||||||
|
--bg-tertiary: #2a2a32;
|
||||||
|
--text-primary: #e0e0e4;
|
||||||
|
--text-secondary: #a0a0a8;
|
||||||
|
--text-muted: #707078;
|
||||||
|
--accent: #6c9bff;
|
||||||
|
--border: #383840;
|
||||||
|
--error: #ff6c6c;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-left, .toolbar-center, .toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 8px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn:hover {
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btn {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 8px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btn.active {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-bar {
|
||||||
|
display: flex;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 4px 4px 0;
|
||||||
|
min-height: 40px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-bottom: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-right: 2px;
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-close {
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.5;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-close:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-message {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-message h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-header {
|
||||||
|
padding: 20px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-header h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-header .server-address {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-btn {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 8px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-btn:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.output {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-line {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-line.command {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-line.response {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-line.error {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-area {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmd-input {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmd-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmd-send {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 28px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmd-send:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar {
|
||||||
|
padding: 8px 20px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 28px;
|
||||||
|
min-width: 420px;
|
||||||
|
max-width: 90vw;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content h2 {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content h3 {
|
||||||
|
margin-top: 20px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-table td {
|
||||||
|
padding: 6px 10px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-table td:first-child {
|
||||||
|
width: 45%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-table code {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-large {
|
||||||
|
min-width: 620px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel, .btn-save {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 10px 28px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-list {
|
||||||
|
max-height: 420px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-info strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-info span {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-actions button {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 8px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-actions button:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
color: var(--error) !important;
|
||||||
|
border-color: var(--error) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-servers {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,456 @@
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-primary: #f5f5f7;
|
||||||
|
--bg-secondary: #ffffff;
|
||||||
|
--bg-tertiary: #eaeaec;
|
||||||
|
--text-primary: #1a1a1e;
|
||||||
|
--text-secondary: #6b6b70;
|
||||||
|
--text-muted: #a0a0a5;
|
||||||
|
--accent: #0066cc;
|
||||||
|
--border: #d0d0d4;
|
||||||
|
--error: #cc3300;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-left, .toolbar-center, .toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 8px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn:hover {
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btn {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 8px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btn.active {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-bar {
|
||||||
|
display: flex;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 4px 4px 0;
|
||||||
|
min-height: 40px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-bottom: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-right: 2px;
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-color: var(--text-primary);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-close {
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.4;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-close:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-message {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-message h1 {
|
||||||
|
font-size: 28px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-header {
|
||||||
|
padding: 20px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-header h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-header .server-address {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-btn {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 8px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-btn:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.output {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 20px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-line {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-line.command {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-line.response {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-line.error {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-area {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmd-input {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmd-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmd-send {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 28px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmd-send:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar {
|
||||||
|
padding: 8px 20px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 28px;
|
||||||
|
min-width: 420px;
|
||||||
|
max-width: 90vw;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content h2 {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content h3 {
|
||||||
|
margin-top: 20px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-table td {
|
||||||
|
padding: 6px 10px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-table td:first-child {
|
||||||
|
width: 45%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-table code {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-large {
|
||||||
|
min-width: 620px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel, .btn-save {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 10px 28px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-list {
|
||||||
|
max-height: 420px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-info strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-info span {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-actions button {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 8px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-actions button:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
color: var(--error) !important;
|
||||||
|
border-color: var(--error) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-servers {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,437 @@
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-primary: #0a0a0a;
|
||||||
|
--bg-secondary: #0f0f0f;
|
||||||
|
--bg-tertiary: #141414;
|
||||||
|
--text-primary: #33ff33;
|
||||||
|
--text-secondary: #22aa22;
|
||||||
|
--text-muted: #1a801a;
|
||||||
|
--accent: #44ff44;
|
||||||
|
--border: #226622;
|
||||||
|
--error: #ff4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-left, .toolbar-center, .toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 6px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-btn:hover {
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btn {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 6px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-btn.active {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-bar {
|
||||||
|
display: flex;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 4px 4px 0;
|
||||||
|
min-height: 36px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs-placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-bottom: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-close {
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-close:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-message {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-message h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-header {
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-header h2 {
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-header .server-address {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-btn {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 6px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qa-btn:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.output {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-line {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-line.command {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-line.response {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-line.error {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-area {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmd-input {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmd-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmd-send {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 8px 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cmd-send:hover {
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar {
|
||||||
|
padding: 6px 16px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 24px;
|
||||||
|
min-width: 400px;
|
||||||
|
max-width: 90vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content h2 {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 16px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content h3 {
|
||||||
|
margin-top: 16px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-table td {
|
||||||
|
padding: 4px 8px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-table td:first-child {
|
||||||
|
width: 45%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-table code {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-large {
|
||||||
|
min-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel, .btn-save {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 8px 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-save {
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-list {
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-info strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-info span {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-actions button {
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 6px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-actions button:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
color: var(--error) !important;
|
||||||
|
border-color: var(--error) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-servers {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue