<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Server Commands</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #0f172a;
font-family: monospace;
color: #e2e8f0;
padding: 20px 24px;
min-height: 100vh;
}
.container {
width: 100%;
max-width: 1840px;
margin: 0 auto;
}
h1 {
font-size: 14px;
margin-bottom: 14px;
color: #64748b;
font-weight: normal;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
h1 .path span { color: #38bdf8; }
h1 .date { color: #64748b; }
h1 .date span { color: #94a3b8; }
.toolbar {
display: flex;
gap: 8px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.toolbar button {
background: #3b82f6;
border: none;
color: white;
padding: 7px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
}
.toolbar button:hover { background: #2563eb; }
.toolbar button.secondary { background: #334155; }
.toolbar button.secondary:hover { background: #475569; }
.add-form {
display: flex;
gap: 8px;
margin-bottom: 18px;
flex-wrap: wrap;
}
.add-form input {
background: #1e293b;
border: 1px solid #334155;
color: #e2e8f0;
padding: 8px 12px;
border-radius: 6px;
font-family: monospace;
font-size: 13px;
outline: none;
}
.add-form input:focus { border-color: #3b82f6; }
.add-form input.cmd-input { flex: 3; min-width: 280px; }
.add-form input.hint-input { flex: 1.5; min-width: 180px; }
.add-form button {
background: #3b82f6;
border: none;
color: white;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.add-form button:hover { background: #2563eb; }
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.cmd {
display: flex;
align-items: center;
gap: 8px;
background: #1e293b;
padding: 8px 10px;
border-radius: 6px;
font-size: 13px;
min-width: 0;
}
.cmd code {
overflow-x: auto;
white-space: nowrap;
flex: 1;
min-width: 0;
scrollbar-width: none;
-ms-overflow-style: none;
}
.cmd code::-webkit-scrollbar { display: none; }
.cmd .actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.icon-btn {
background: #334155;
border: none;
color: #e2e8f0;
width: 28px;
height: 28px;
border-radius: 5px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
position: relative;
transition: background 0.2s, color 0.2s;
}
.icon-btn:hover { background: #3b82f6; }
.icon-btn.delete:hover { background: #ef4444; }
.icon-btn.copied {
background: #1fff8f !important;
color: #0f172a;
}
.icon-btn svg {
width: 15px;
height: 15px;
pointer-events: none;
}
.icon-btn .tooltip {
visibility: hidden;
opacity: 0;
position: absolute;
bottom: calc(100% + 7px);
left: 50%;
transform: translateX(-50%);
background: #1e293b;
border: 1px solid #475569;
color: #e2e8f0;
padding: 6px 10px;
border-radius: 5px;
font-size: 12px;
white-space: nowrap;
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
z-index: 20;
pointer-events: none;
transition: opacity 0.15s;
}
.icon-btn .tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
margin-left: -5px;
border: 5px solid transparent;
border-top-color: #475569;
}
.icon-btn:hover .tooltip { visibility: visible; opacity: 1; }
.icon-btn .tooltip.empty { display: none; }
.empty {
grid-column: 1 / -1;
text-align: center;
color: #64748b;
padding: 50px 0;
font-size: 14px;
}
#fileInput { display: none; }
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.65);
z-index: 100;
align-items: center;
justify-content: center;
}
.modal-overlay.active { display: flex; }
.modal {
background: #1e293b;
border: 1px solid #334155;
border-radius: 10px;
padding: 22px 24px;
width: 90%;
max-width: 480px;
box-shadow: 0 20px 50px rgba(0,0,0,0.5);
}
.modal h2 {
font-size: 16px;
font-weight: normal;
color: #94a3b8;
margin-bottom: 14px;
}
.modal .cmd-preview {
background: #0f172a;
padding: 10px 12px;
border-radius: 6px;
font-size: 13px;
margin-bottom: 18px;
word-break: break-all;
max-height: 80px;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.modal .cmd-preview::-webkit-scrollbar { display: none; }
.modal .info-text {
background: #0f172a;
padding: 12px 14px;
border-radius: 6px;
font-size: 13px;
margin-bottom: 18px;
line-height: 1.5;
color: #e2e8f0;
}
.modal .info-text .ok { color: #1fff8f; }
.modal-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.modal-actions button {
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-family: monospace;
}
.modal-actions .btn-edit { background: #3b82f6; color: white; }
.modal-actions .btn-edit:hover { background: #2563eb; }
.modal-actions .btn-delete { background: #ef4444; color: white; }
.modal-actions .btn-delete:hover { background: #dc2626; }
.modal-actions .btn-cancel { background: #334155; color: #e2e8f0; }
.modal-actions .btn-cancel:hover { background: #475569; }
.modal-actions .btn-save { background: #22c55e; color: white; }
.modal-actions .btn-save:hover { background: #16a34a; }
.modal-actions .btn-ok { background: #1fff8f; color: #0f172a; }
.modal-actions .btn-ok:hover { background: #00e676; }
.modal-form {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 18px;
}
.modal-form label {
font-size: 12px;
color: #94a3b8;
}
.modal-form input {
background: #0f172a;
border: 1px solid #334155;
color: #e2e8f0;
padding: 9px 12px;
border-radius: 6px;
font-family: monospace;
font-size: 13px;
outline: none;
width: 100%;
}
.modal-form input:focus { border-color: #3b82f6; }
@media (max-width: 1100px) {
.grid { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 600px) {
.grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="container">
<h1>
<span class="path">JSON: <span id="jsonPath">commands.json</span></span>
<span class="date">· последнее сохранение: <span id="jsonDate">—</span></span>
</h1>
<div class="toolbar">
<button onclick="exportCommands()">Сохранить</button>
<button class="secondary" onclick="document.getElementById('fileInput').click()">Загрузить</button>
<button class="secondary" onclick="clearAll()">Очистить всё</button>
<input type="file" id="fileInput" accept=".json" onchange="importCommands(event)">
</div>
<div class="add-form">
<input type="text" id="cmdInput" class="cmd-input" placeholder="Команда..." autocomplete="off">
<input type="text" id="hintInput" class="hint-input" placeholder="Подсказка (опционально)" autocomplete="off">
<button onclick="addCommand()">Добавить</button>
</div>
<div class="grid" id="commands"></div>
</div>
<div class="modal-overlay" id="actionModal">
<div class="modal">
<h2>Действие с командой</h2>
<div class="cmd-preview" id="actionPreview"></div>
<div class="modal-actions">
<button class="btn-cancel" onclick="closeModals()">Отмена</button>
<button class="btn-edit" onclick="openEditModal()">Редактировать</button>
<button class="btn-delete" onclick="confirmDelete()">Удалить</button>
</div>
</div>
</div>
<div class="modal-overlay" id="editModal">
<div class="modal">
<h2>Редактировать команду</h2>
<div class="modal-form">
<div>
<label>Команда</label>
<input type="text" id="editText" autocomplete="off">
</div>
<div>
<label>Подсказка</label>
<input type="text" id="editHint" autocomplete="off">
</div>
</div>
<div class="modal-actions">
<button class="btn-cancel" onclick="closeModals()">Отмена</button>
<button class="btn-save" onclick="saveEdit()">Сохранить</button>
</div>
</div>
</div>
<div class="modal-overlay" id="importModal">
<div class="modal">
<h2>Загрузка</h2>
<div class="info-text" id="importMessage"></div>
<div class="modal-actions">
<button class="btn-ok" onclick="closeModals()">OK</button>
</div>
</div>
</div>
<script>
const STORAGE_KEY = 'server-commands-v2';
const PATH_KEY = 'server-commands-path';
const DATE_KEY = 'server-commands-date';
let currentIndex = null;
const defaultCommands = [
{ text: 'apt update', hint: 'Обновить список пакетов' },
{ text: 'apt install -y python3 python3-venv python3-pip', hint: 'Установить Python и pip' },
{ text: 'apt install ufw', hint: 'Установить файрвол' },
{ text: 'python3 -m venv venv', hint: 'Создать виртуальное окружение' },
{ text: 'source venv/bin/activate', hint: 'Активировать venv' },
{ text: 'pip install -r requirements.txt', hint: 'Установить зависимости' },
{ text: 'uvicorn main:app --reload --host 192.168.55.44 --port 888', hint: 'Запуск с hot-reload' },
{ text: 'uvicorn main:app --host 192.168.55.44 --port 545', hint: 'Продакшн запуск' },
{ text: 'kill -9 $(ss -tulnp | grep :444 | awk -F\',\' \'{print $2}\' | awk -F\'=\' \'{print $2}\')', hint: 'Убить процесс на порту 444' }
];
function formatDate(iso) {
if (!iso) return '—';
const d = new Date(iso);
if (isNaN(d.getTime())) return '—';
const pad = n => String(n).padStart(2, '0');
return `${pad(d.getDate())}.${pad(d.getMonth()+1)}.${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function getJsonPath() {
return localStorage.getItem(PATH_KEY) || 'commands.json';
}
function setJsonPath(name) {
localStorage.setItem(PATH_KEY, name);
document.getElementById('jsonPath').textContent = name;
}
function setSaveDate(iso) {
localStorage.setItem(DATE_KEY, iso || '');
document.getElementById('jsonDate').textContent = formatDate(iso);
}
function loadCommands() {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) return JSON.parse(saved);
localStorage.setItem(STORAGE_KEY, JSON.stringify(defaultCommands));
return defaultCommands;
}
function saveCommands(cmds) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(cmds));
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function render() {
const cmds = loadCommands();
const container = document.getElementById('commands');
if (cmds.length === 0) {
container.innerHTML = '<div class="empty">Нет команд. Добавь первую!</div>';
return;
}
container.innerHTML = cmds.map((item, i) => {
const hint = item.hint || '';
const hasHint = hint.trim().length > 0;
return `
<div class="cmd">
<code>${escapeHtml(item.text)}</code>
<div class="actions">
<button class="icon-btn" onclick="copyCmd(this, ${i})">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
<span class="tooltip ${hasHint ? '' : 'empty'}">${escapeHtml(hint || 'Нет подсказки')}</span>
</button>
<button class="icon-btn delete" onclick="openActionModal(${i})">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
</div>
`;
}).join('');
}
function addCommand() {
const cmdInput = document.getElementById('cmdInput');
const hintInput = document.getElementById('hintInput');
const text = cmdInput.value.trim();
if (!text) return;
const cmds = loadCommands();
cmds.push({ text: text, hint: hintInput.value.trim() });
saveCommands(cmds);
cmdInput.value = '';
hintInput.value = '';
render();
cmdInput.focus();
}
function openActionModal(index) {
currentIndex = index;
const cmds = loadCommands();
document.getElementById('actionPreview').textContent = cmds[index].text;
document.getElementById('actionModal').classList.add('active');
}
function openEditModal() {
const cmds = loadCommands();
const item = cmds[currentIndex];
document.getElementById('editText').value = item.text;
document.getElementById('editHint').value = item.hint || '';
document.getElementById('actionModal').classList.remove('active');
document.getElementById('editModal').classList.add('active');
document.getElementById('editText').focus();
}
function confirmDelete() {
const cmds = loadCommands();
cmds.splice(currentIndex, 1);
saveCommands(cmds);
closeModals();
render();
}
function saveEdit() {
const text = document.getElementById('editText').value.trim();
if (!text) return;
const cmds = loadCommands();
cmds[currentIndex] = {
text: text,
hint: document.getElementById('editHint').value.trim()
};
saveCommands(cmds);
closeModals();
render();
}
function closeModals() {
document.getElementById('actionModal').classList.remove('active');
document.getElementById('editModal').classList.remove('active');
document.getElementById('importModal').classList.remove('active');
currentIndex = null;
}
document.querySelectorAll('.modal-overlay').forEach(el => {
el.addEventListener('click', e => {
if (e.target === el) closeModals();
});
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeModals();
});
function copyCmd(btn, index) {
const cmds = loadCommands();
navigator.clipboard.writeText(cmds[index].text).then(() => {
btn.classList.add('copied');
const svg = btn.querySelector('svg');
svg.innerHTML = `<polyline points="20 6 9 17 4 12"></polyline>`;
setTimeout(() => {
btn.classList.remove('copied');
svg.innerHTML = `
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
`;
}, 1200);
});
}
// Сохранить — пишет savedAt в файл, но НЕ трогает дату на экране
function exportCommands() {
const cmds = loadCommands();
const now = new Date().toISOString();
const payload = {
savedAt: now,
commands: cmds
};
const filename = getJsonPath();
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
// дату на экране НЕ обновляем — она меняется только при Загрузить
}
// Загрузить — единственное место, где обновляется дата (из файла)
function importCommands(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
try {
let raw = JSON.parse(e.target.result);
let cmds, savedAt;
if (raw && Array.isArray(raw.commands)) {
cmds = raw.commands;
savedAt = raw.savedAt || null;
} else if (Array.isArray(raw)) {
if (raw.length && typeof raw[0] === 'string') {
cmds = raw.map(t => ({ text: t, hint: '' }));
} else {
cmds = raw;
}
savedAt = null;
} else {
throw new Error('Неверный формат');
}
saveCommands(cmds);
setJsonPath(file.name);
setSaveDate(savedAt); // дата из JSON-файла
document.getElementById('importMessage').innerHTML =
`<span class="ok">✓</span> Загружено <b>${cmds.length}</b> команд<br>` +
`Файл: <b>${escapeHtml(file.name)}</b><br>` +
`Последнее сохранение: <b>${formatDate(savedAt)}</b>`;
document.getElementById('importModal').classList.add('active');
render();
} catch (err) {
document.getElementById('importMessage').innerHTML =
`Ошибка: нужен JSON с полем <code>commands</code> или массив команд`;
document.getElementById('importModal').classList.add('active');
}
};
reader.readAsText(file);
event.target.value = '';
}
function clearAll() {
if (confirm('Удалить ВСЕ команды?')) {
saveCommands([]);
render();
}
}
document.getElementById('cmdInput').addEventListener('keydown', e => {
if (e.key === 'Enter') addCommand();
});
document.getElementById('hintInput').addEventListener('keydown', e => {
if (e.key === 'Enter') addCommand();
});
document.getElementById('editText').addEventListener('keydown', e => {
if (e.key === 'Enter') saveEdit();
});
document.getElementById('editHint').addEventListener('keydown', e => {
if (e.key === 'Enter') saveEdit();
});
document.getElementById('jsonPath').textContent = getJsonPath();
document.getElementById('jsonDate').textContent = formatDate(localStorage.getItem(DATE_KEY));
render();
</script>
</body>
</html>