2 Commits

Author SHA1 Message Date
jamie db33658ffa Merge pull request 'V1.2.0' (#17) from v1.2.0 into main
Reviewed-on: http://git.jdbnet.co.uk/jamie/ssh/pulls/17
2026-06-19 20:03:17 +01:00
jamie a38e3488e4 feat: add support for managing and executing reusable command snippets
Release / release (pull_request) Successful in 23s
2026-06-19 20:02:07 +01:00
4 changed files with 351 additions and 2 deletions
+81
View File
@@ -164,6 +164,13 @@ def init_db():
CONSTRAINT fk_host_tags_tag FOREIGN KEY (tag_id) CONSTRAINT fk_host_tags_tag FOREIGN KEY (tag_id)
REFERENCES ssh_tags(id) ON DELETE CASCADE REFERENCES ssh_tags(id) ON DELETE CASCADE
); );
CREATE TABLE IF NOT EXISTS ssh_snippets (
id INT AUTO_INCREMENT PRIMARY KEY,
label VARCHAR(255) NOT NULL,
command TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
""" """
with db_cursor() as (_, cur): with db_cursor() as (_, cur):
for stmt in ddl.split(";"): for stmt in ddl.split(";"):
@@ -1103,6 +1110,80 @@ def delete_identity(iid: int):
return jsonify({"ok": True}) return jsonify({"ok": True})
@app.route("/api/snippets", methods=["GET"])
@require_auth("read:hosts")
def list_snippets():
with db_cursor() as (_, cur):
cur.execute("SELECT id, label, command, created_at, updated_at FROM ssh_snippets ORDER BY label ASC")
rows = cur.fetchall()
return jsonify({"items": rows})
@app.route("/api/snippets", methods=["POST"])
@require_auth("write:hosts")
def create_snippet():
req = request.get_json()
if not req:
return jsonify({"error": "invalid json"}), 400
label = (req.get("label") or "").strip()
command = req.get("command") or ""
if not label or not command:
return jsonify({"error": "label and command required"}), 400
with db_cursor() as (_, cur):
cur.execute(
"INSERT INTO ssh_snippets (label, command) VALUES (%s, %s)",
(label, command),
)
sid = cur.lastrowid
return jsonify({"id": sid}), 201
@app.route("/api/snippets/<int:sid>", methods=["PATCH"])
@require_auth("write:hosts")
def update_snippet(sid: int):
req = request.get_json()
if not req:
return jsonify({"error": "invalid json"}), 400
updates = []
args = []
if "label" in req:
label = (req["label"] or "").strip()
if not label:
return jsonify({"error": "label cannot be empty"}), 400
updates.append("label = %s")
args.append(label)
if "command" in req:
cmd = req["command"] or ""
if not cmd:
return jsonify({"error": "command cannot be empty"}), 400
updates.append("command = %s")
args.append(cmd)
if not updates:
return jsonify({"ok": True})
args.append(sid)
with db_cursor() as (_, cur):
cur.execute(
f"UPDATE ssh_snippets SET {', '.join(updates)} WHERE id = %s",
tuple(args),
)
if cur.rowcount == 0:
return jsonify({"error": "not found"}), 404
return jsonify({"ok": True})
@app.route("/api/snippets/<int:sid>", methods=["DELETE"])
@require_auth("write:hosts")
def delete_snippet(sid: int):
with db_cursor() as (_, cur):
cur.execute("DELETE FROM ssh_snippets WHERE id = %s", (sid,))
if cur.rowcount == 0:
return jsonify({"error": "not found"}), 404
return jsonify({"ok": True})
def _host_select_sql(extra_where: str = "") -> str: def _host_select_sql(extra_where: str = "") -> str:
return f""" return f"""
SELECT h.id, h.folder_id, h.label, h.hostname, h.port, h.identity_id, h.jump_host_id, SELECT h.id, h.folder_id, h.label, h.hostname, h.port, h.identity_id, h.jump_host_id,
+138 -2
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from "vue"; import { onMounted, onUnmounted, ref } from "vue";
import { Folder, Pencil, Trash2, Radio } from "lucide-vue-next"; import { Folder, Pencil, Trash2, Radio, ChevronDown } from "lucide-vue-next";
import { import {
api, api,
type HostRow, type HostRow,
@@ -9,10 +9,12 @@ import {
type ConnectionAuditRow, type ConnectionAuditRow,
type ApiKeyRow, type ApiKeyRow,
type ApiKeyScopeDef, type ApiKeyScopeDef,
type SnippetRow,
} from "@/api"; } from "@/api";
import LoginForm from "@/components/LoginForm.vue"; import LoginForm from "@/components/LoginForm.vue";
import TabContent from "@/components/TabContent.vue"; import TabContent from "@/components/TabContent.vue";
import TagInput from "@/components/TagInput.vue"; import TagInput from "@/components/TagInput.vue";
import SnippetForm from "@/components/SnippetForm.vue";
interface TabItem { interface TabItem {
id: string; id: string;
@@ -53,6 +55,79 @@ function handleBroadcast(data: string, sourceId: string) {
} }
} }
const snippets = ref<SnippetRow[]>([]);
const showSnippetsMenu = ref(false);
const showSnippetForm = ref(false);
const editingSnippet = ref<SnippetRow | null>(null);
const snippetsButtonRef = ref<HTMLElement | null>(null);
const snippetsMenuRef = ref<HTMLElement | null>(null);
function closeSnippetsMenu(e: MouseEvent) {
if (
showSnippetsMenu.value &&
!snippetsButtonRef.value?.contains(e.target as Node) &&
!snippetsMenuRef.value?.contains(e.target as Node)
) {
showSnippetsMenu.value = false;
}
}
onMounted(() => {
document.addEventListener("click", closeSnippetsMenu);
});
onUnmounted(() => {
document.removeEventListener("click", closeSnippetsMenu);
});
async function loadSnippets() {
try {
snippets.value = await api.listSnippets();
} catch (err: any) {
loadErr.value = err.message;
}
}
function runSnippet(command: string) {
showSnippetsMenu.value = false;
const targetPanes = broadcastMode.value ? activePanes.value : [activePanes.value[0]];
for (const paneId of targetPanes) {
if (paneId && tabRefs.value[paneId]) {
tabRefs.value[paneId].sendData(command + "\r");
}
}
}
async function saveSnippet(data: { label: string; command: string }) {
try {
if (editingSnippet.value) {
await api.updateSnippet(editingSnippet.value.id, data);
} else {
await api.createSnippet(data);
}
showSnippetForm.value = false;
await loadSnippets();
} catch (err: any) {
alert(err.message);
}
}
async function deleteSnippet(id: number) {
if (!confirm("Are you sure you want to delete this snippet?")) return;
try {
await api.deleteSnippet(id);
await loadSnippets();
} catch (err: any) {
alert(err.message);
}
}
function openEditSnippet(s?: SnippetRow) {
showSnippetsMenu.value = false;
editingSnippet.value = s || null;
showSnippetForm.value = true;
}
function onTabDragStart(id: string) { function onTabDragStart(id: string) {
draggedTabId.value = id; draggedTabId.value = id;
} }
@@ -255,6 +330,7 @@ async function refreshData() {
allHosts.value = await api.listHosts(); allHosts.value = await api.listHosts();
allFolders.value = await api.listFoldersFlat(); allFolders.value = await api.listFoldersFlat();
allTags.value = await api.listTags(); allTags.value = await api.listTags();
await loadSnippets();
if (!hostForm.value.identity_id && identities.value.length) { if (!hostForm.value.identity_id && identities.value.length) {
hostForm.value.identity_id = identities.value[0].id; hostForm.value.identity_id = identities.value[0].id;
} }
@@ -773,6 +849,59 @@ async function deleteIdentityRow(id: number) {
Broadcast Broadcast
</span> </span>
</button> </button>
<div class="relative hidden md:block">
<button
ref="snippetsButtonRef"
type="button"
class="rounded-lg px-3 py-1.5 text-xs text-slate-400 hover:bg-slate-800 hover:text-white inline-flex items-center gap-1"
@click="showSnippetsMenu = !showSnippetsMenu"
>
Snippets
<ChevronDown class="h-3 w-3" />
</button>
<div
v-if="showSnippetsMenu"
ref="snippetsMenuRef"
class="absolute right-0 top-full mt-2 w-64 rounded-xl border border-slate-700 bg-surface shadow-xl z-50 overflow-hidden"
>
<div class="max-h-64 overflow-y-auto p-1">
<div v-if="snippets.length === 0" class="p-3 text-center text-xs text-slate-500">
No snippets saved.
</div>
<div
v-for="s in snippets"
:key="s.id"
class="flex items-center justify-between group rounded-lg px-2 py-1.5 hover:bg-slate-800"
>
<button
type="button"
class="flex-1 text-left text-sm text-white truncate"
@click="runSnippet(s.command)"
:title="s.command"
>
{{ s.label }}
</button>
<div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button type="button" class="text-slate-400 hover:text-white p-1" @click="openEditSnippet(s)">
<Pencil class="h-3 w-3" />
</button>
<button type="button" class="text-red-400 hover:text-red-300 p-1" @click="deleteSnippet(s.id)">
<Trash2 class="h-3 w-3" />
</button>
</div>
</div>
</div>
<div class="border-t border-slate-700 p-1">
<button
type="button"
class="w-full rounded-lg px-2 py-1.5 text-left text-xs text-slate-400 hover:bg-slate-800 hover:text-white"
@click="openEditSnippet()"
>
+ Add new snippet...
</button>
</div>
</div>
</div>
<button <button
type="button" type="button"
class="hidden rounded-lg px-3 py-1.5 text-xs md:inline-flex" class="hidden rounded-lg px-3 py-1.5 text-xs md:inline-flex"
@@ -1117,6 +1246,13 @@ async function deleteIdentityRow(id: number) {
</main> </main>
</div> </div>
<SnippetForm
v-if="showSnippetForm"
:snippet="editingSnippet"
@save="saveSnippet"
@cancel="showSnippetForm = false"
/>
<div <div
v-if="showApiKeys" v-if="showApiKeys"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
+43
View File
@@ -188,6 +188,43 @@ export const api = {
await handle(res); await handle(res);
}, },
async listSnippets(): Promise<SnippetRow[]> {
const res = await fetch("/api/snippets", { credentials: "include" });
const d = await handle<{ items: SnippetRow[] }>(res);
return d.items;
},
async createSnippet(body: Record<string, unknown>): Promise<{ id: number }> {
const res = await fetch("/api/snippets", {
method: "POST",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify(body),
});
return handle(res);
},
async updateSnippet(
id: number,
body: Partial<{ label: string; command: string }>,
): Promise<void> {
const res = await fetch(`/api/snippets/${id}`, {
method: "PATCH",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify(body),
});
await handle(res);
},
async deleteSnippet(id: number): Promise<void> {
const res = await fetch(`/api/snippets/${id}`, {
method: "DELETE",
credentials: "include",
});
await handle(res);
},
async listConnectionAudit(limit = 200, daysBack?: number): Promise<ConnectionAuditRow[]> { async listConnectionAudit(limit = 200, daysBack?: number): Promise<ConnectionAuditRow[]> {
const q = new URLSearchParams({ limit: String(limit) }); const q = new URLSearchParams({ limit: String(limit) });
if (daysBack !== undefined) { if (daysBack !== undefined) {
@@ -327,6 +364,12 @@ export interface IdentityRow {
auth_type: string; auth_type: string;
} }
export interface SnippetRow {
id: number;
label: string;
command: string;
}
export interface SftpEntry { export interface SftpEntry {
filename: string; filename: string;
st_mode: number; st_mode: number;
+89
View File
@@ -0,0 +1,89 @@
<script setup lang="ts">
import { ref } from "vue";
import type { SnippetRow } from "@/api";
const props = defineProps<{
snippet?: SnippetRow | null;
}>();
const emit = defineEmits<{
(e: "save", data: { label: string; command: string }): void;
(e: "cancel"): void;
}>();
const label = ref(props.snippet?.label || "");
const command = ref(props.snippet?.command || "");
function submit() {
if (!label.value.trim() || !command.value.trim()) return;
emit("save", {
label: label.value.trim(),
command: command.value.trim(),
});
}
</script>
<template>
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
>
<div
class="w-full max-w-md rounded-xl border border-slate-700 bg-surface shadow-2xl"
>
<div class="flex items-center justify-between border-b border-slate-800 p-4">
<h2 class="text-lg font-semibold text-white">
{{ snippet ? "Edit snippet" : "Add snippet" }}
</h2>
<button
type="button"
class="text-slate-400 hover:text-white"
@click="emit('cancel')"
>
&times;
</button>
</div>
<form class="space-y-4 p-4" @submit.prevent="submit">
<div>
<label class="mb-1 block text-sm font-medium text-slate-300">
Label
</label>
<input
v-model="label"
type="text"
required
class="w-full rounded-lg border border-slate-700 bg-surface-overlay px-3 py-2 text-sm text-white placeholder:text-slate-500 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
placeholder="e.g. Docker logs"
autocomplete="off"
/>
</div>
<div>
<label class="mb-1 block text-sm font-medium text-slate-300">
Command
</label>
<textarea
v-model="command"
required
rows="3"
class="w-full rounded-lg border border-slate-700 bg-surface-overlay px-3 py-2 text-sm font-mono text-white placeholder:text-slate-500 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
placeholder="docker compose logs -f"
/>
</div>
<div class="flex justify-end gap-2 pt-2">
<button
type="button"
class="rounded-lg px-4 py-2 text-sm font-medium text-slate-300 hover:text-white"
@click="emit('cancel')"
>
Cancel
</button>
<button
type="submit"
class="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-black hover:bg-[#16966b]"
>
Save snippet
</button>
</div>
</form>
</div>
</div>
</template>