29 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
jamie 6539a7f2c4 feat: implement terminal input broadcasting across multiple active panes 2026-06-19 19:54:31 +01:00
jamie 790cfc581a style: update design system with new color palette and typography fonts 2026-06-19 19:48:56 +01:00
jamie 053c9e8431 feat: add tab dragging and multi-pane split view support to App layout 2026-06-19 19:42:59 +01:00
jamie 59d216f697 chore: rename application title from JDB-NET SSH to SSH across UI components 2026-06-19 19:34:58 +01:00
jamie d81136fe5e feat: add toggleable SFTP panel 2026-06-19 19:29:44 +01:00
jamie e43c4a7f2a feat: automate virtual environment setup and use exec for gunicorn process management 2026-06-19 19:12:53 +01:00
jamie 07c9da8a80 Merge pull request 'refactor: 🎨 remove package-lock.json from root' (#16) from v1.1.1 into main
Reviewed-on: http://git.jdbnet.co.uk/jamie/ssh/pulls/16
2026-06-02 23:25:43 +01:00
jamie 4bdd4c1d8a fix: 🐛 only have version number clickable instead of title
Release / release (pull_request) Successful in 21s
2026-06-02 22:25:15 +00:00
jamie 336334c7f5 style: 🎨 hide extra nav items on mobile 2026-06-02 22:23:47 +00:00
jamie a502ae2687 fix: 🐛 ability to delete expired api keys 2026-06-02 22:20:56 +00:00
jamie 782d8446d9 build: 🚀 add run.sh for testing locally 2026-06-02 22:19:22 +00:00
jamie 5b79f5fb4b feat: ability to delete api keys 2026-06-02 22:16:49 +00:00
jamie a0f84ec78e refactor: 🎨 remove package-lock.json from root 2026-06-02 22:13:21 +00:00
jamie da996705c9 Merge pull request 'feat: added api key support' (#13) from v1.1.0 into main
Reviewed-on: http://git.jdbnet.co.uk/jamie/ssh/pulls/13
2026-05-23 16:42:43 +01:00
jamie 6d341309f1 docs: 📝 remove unrequired variable from example
Release / release (pull_request) Successful in 24s
2026-05-23 15:42:22 +00:00
jamie 187a3c7882 fix: 🐛 client timeouts 2026-05-23 15:40:13 +00:00
jamie c7ffdf81c2 feat: add tags 2026-05-23 15:36:46 +00:00
jamie 0664d8763d feat: added api key support 2026-05-23 15:24:35 +00:00
jamie 853e06456e test: remove dummy project 2026-05-15 07:05:02 +00:00
jamie cae073728a Merge pull request 'fix: 🐛 branch name' (#11) from v1.0.0 into main
Reviewed-on: #11
2026-05-15 08:02:01 +01:00
jamie 0f35d5bd6f fix: 🐛 branch name
Release / release (pull_request) Successful in 26s
2026-05-15 07:01:38 +00:00
jamie 1d6cce88a8 Merge pull request 'feat: first release' (#10) from v1.0.0 into main
Reviewed-on: #10
2026-05-15 08:00:58 +01:00
jamie 18e256baee feat: first release 2026-05-15 07:00:35 +00:00
jamie 7682a94981 fix: 🐛 one-time credentials
CI / Build and Push (push) Successful in 4s
CI / SonarQube (push) Successful in 31s
2026-05-14 14:54:43 +00:00
jamie bb724377fe style: 🎨 more icons and ability to edit folders
CI / Build and Push (push) Successful in 8s
CI / SonarQube (push) Successful in 31s
2026-05-14 12:44:41 +00:00
jamie 22a3dc7cbe style: 🎨 delete icon and reduce icon size
CI / Build and Push (push) Successful in 7s
CI / SonarQube (push) Successful in 31s
2026-05-14 12:38:36 +00:00
jamie 6069f5395a style: 🎨 edit icon
CI / Build and Push (push) Successful in 8s
CI / SonarQube (push) Successful in 31s
2026-05-14 12:34:04 +00:00
19 changed files with 1863 additions and 205 deletions
+5
View File
@@ -1,5 +1,10 @@
FROM mcr.microsoft.com/devcontainers/python:3.14 FROM mcr.microsoft.com/devcontainers/python:3.14
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y nodejs \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /workspace WORKDIR /workspace
CMD ["sleep", "infinity"] CMD ["sleep", "infinity"]
+4 -1
View File
@@ -26,4 +26,7 @@ MYSQL_POOL_SIZE=5
MAX_CONCURRENT_SSH=32 MAX_CONCURRENT_SSH=32
# Paramiko SSH keepalive interval (seconds); set 0 to disable. # Paramiko SSH keepalive interval (seconds); set 0 to disable.
SSH_KEEPALIVE_INTERVAL=30 SSH_KEEPALIVE_INTERVAL=15
# WebSocket keepalive interval (seconds); server sends traffic to avoid proxy idle timeouts.
WS_KEEPALIVE_INTERVAL=25
-46
View File
@@ -1,46 +0,0 @@
name: CI
on:
workflow_dispatch:
jobs:
build-and-push:
name: Build and Push
runs-on: build-htz-01
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build and push Docker image
run: |
docker build -t cr.jdbnet.co.uk/public/ssh:latest .
docker push cr.jdbnet.co.uk/public/ssh:latest
sonarqube:
name: SonarQube
runs-on: build-htz-01
steps:
- name: Checkout Code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Create Valid Project Key
id: sonar_setup
run: |
CLEAN_KEY=$(echo "${{ gitea.repository }}" | tr '/' ':')
echo "key=$CLEAN_KEY" >> $GITHUB_OUTPUT
- name: SonarQube Scan
uses: sonarsource/sonarqube-scan-action@master
continue-on-error: true
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
with:
args: >
-Dsonar.projectKey=${{ steps.sonar_setup.outputs.key }}
-Dsonar.projectName=${{ gitea.repository }}
-Dsonar.qualitygate.wait=true
-2
View File
@@ -1,8 +1,6 @@
name: CI name: CI
on: on:
push:
branches: [ main ]
workflow_dispatch: workflow_dispatch:
jobs: jobs:
+46
View File
@@ -0,0 +1,46 @@
name: Release
on:
pull_request:
branches:
- main
types: [closed]
jobs:
release:
if: github.event.pull_request.merged == true && startsWith(github.head_ref, 'v')
runs-on: build-htz-01
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract Version
id: get_version
run: echo "VERSION=${{ github.head_ref }}" >> $GITHUB_OUTPUT
- name: Generate Changelog
id: changelog
uses: https://github.com/metcalfc/changelog-generator@v4.6.2
with:
myToken: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
run: |
VERSION=${{ steps.get_version.outputs.VERSION }}
docker build -t cr.jdbnet.co.uk/public/ssh:$VERSION \
-t cr.jdbnet.co.uk/public/ssh:latest \
--build-arg VERSION=$VERSION \
.
docker push cr.jdbnet.co.uk/public/ssh:$VERSION
docker push cr.jdbnet.co.uk/public/ssh:latest
- name: Create Gitea Release
uses: https://gitea.com/actions/gitea-release-action@v1
with:
tag_name: ${{ steps.get_version.outputs.VERSION }}
name: ${{ steps.get_version.outputs.VERSION }}
body: ${{ steps.changelog.outputs.changelog }}
draft: false
prerelease: false
+1
View File
@@ -2,3 +2,4 @@ __pycache__/
.env .env
frontend/node_modules/ frontend/node_modules/
static/dist/ static/dist/
venv/
+2
View File
@@ -8,6 +8,8 @@ RUN npm run build
FROM python:3.14-slim FROM python:3.14-slim
LABEL org.opencontainers.image.vendor="JDB-NET" LABEL org.opencontainers.image.vendor="JDB-NET"
WORKDIR /app WORKDIR /app
ARG VERSION=unknown
ENV VERSION=${VERSION}
COPY requirements.txt . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY . /app COPY . /app
-1
View File
@@ -30,7 +30,6 @@ services:
ports: ports:
- "5000:5000" - "5000:5000"
environment: environment:
GEVENT_MONKEY_PATCH: "1"
MYSQL_HOST: "<YOUR_MYSQL_HOST>" MYSQL_HOST: "<YOUR_MYSQL_HOST>"
MYSQL_DATABASE: "<YOUR_MYSQL_DATABASE>" MYSQL_DATABASE: "<YOUR_MYSQL_DATABASE>"
MYSQL_USER: "<YOUR_MYSQL_USER>" MYSQL_USER: "<YOUR_MYSQL_USER>"
+670 -44
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -8,15 +8,15 @@
href="https://assets.jdbnet.co.uk/projects/ssh.png" href="https://assets.jdbnet.co.uk/projects/ssh.png"
/> />
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#0f1419" /> <meta name="theme-color" content="#0d1117" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap" href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Space+Grotesk:wght@400;500;600;700&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
<title>JDB-NET SSH</title> <title>SSH</title>
</head> </head>
<body class="bg-surface text-slate-200 antialiased"> <body class="bg-surface text-slate-200 antialiased">
<div id="app"></div> <div id="app"></div>
+688 -37
View File
@@ -1,15 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from "vue"; import { onMounted, onUnmounted, ref } from "vue";
import { Folder } from "lucide-vue-next"; import { Folder, Pencil, Trash2, Radio, ChevronDown } from "lucide-vue-next";
import { import {
api, api,
type HostRow, type HostRow,
type IdentityRow, type IdentityRow,
type FolderRow, type FolderRow,
type ConnectionAuditRow, type ConnectionAuditRow,
type ApiKeyRow,
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 SnippetForm from "@/components/SnippetForm.vue";
interface TabItem { interface TabItem {
id: string; id: string;
@@ -19,17 +24,142 @@ interface TabItem {
const loggedIn = ref(false); const loggedIn = ref(false);
const checking = ref(true); const checking = ref(true);
const appVersion = ref("unknown");
const identities = ref<IdentityRow[]>([]); const identities = ref<IdentityRow[]>([]);
const allHosts = ref<HostRow[]>([]); const allHosts = ref<HostRow[]>([]);
const allFolders = ref<FolderRow[]>([]); const allFolders = ref<FolderRow[]>([]);
const allTags = ref<string[]>([]);
const browseFolders = ref<FolderRow[]>([]); const browseFolders = ref<FolderRow[]>([]);
const browseHosts = ref<HostRow[]>([]); const browseHosts = ref<HostRow[]>([]);
const breadcrumb = ref<{ id: number; label: string }[]>([]); const breadcrumb = ref<{ id: number; label: string }[]>([]);
const searchActive = ref(false); const searchActive = ref(false);
const currentFolderId = ref<number | null>(null); const currentFolderId = ref<number | null>(null);
const searchQuery = ref(""); const searchQuery = ref("");
const tabs = ref<TabItem[]>([]); const tabs = ref<{ id: string; hostId: number; label: string }[]>([]);
const activeTabId = ref<string | null>(null); const activePanes = ref<string[]>([]);
const draggedTabId = ref<string | null>(null);
const broadcastMode = ref(false);
const tabRefs = ref<Record<string, any>>({});
function setTabRef(el: any, id: string) {
if (el) tabRefs.value[id] = el;
else delete tabRefs.value[id];
}
function handleBroadcast(data: string, sourceId: string) {
if (!broadcastMode.value) return;
for (const paneId of activePanes.value) {
if (paneId !== sourceId && tabRefs.value[paneId]) {
tabRefs.value[paneId].sendData(data);
}
}
}
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) {
draggedTabId.value = id;
}
function onTabDrop(targetId: string) {
if (!draggedTabId.value || draggedTabId.value === targetId) return;
const from = tabs.value.findIndex((t) => t.id === draggedTabId.value);
const to = tabs.value.findIndex((t) => t.id === targetId);
if (from === -1 || to === -1) return;
const [t] = tabs.value.splice(from, 1);
tabs.value.splice(to, 0, t);
draggedTabId.value = null;
}
function onTabDragEnd() {
draggedTabId.value = null;
}
function onSplitDrop() {
if (!draggedTabId.value || activePanes.value.includes(draggedTabId.value)) {
draggedTabId.value = null;
return;
}
if (activePanes.value.length >= 2) {
activePanes.value = [activePanes.value[0], draggedTabId.value];
} else if (activePanes.value.length === 1) {
activePanes.value.push(draggedTabId.value);
} else {
activePanes.value = [draggedTabId.value];
}
draggedTabId.value = null;
}
const loadErr = ref(""); const loadErr = ref("");
const hostSortOrder = ref<"name" | "last_connected">("name"); const hostSortOrder = ref<"name" | "last_connected">("name");
/** Narrow viewports: slide-over hosts panel; md+ sidebar stays visible */ /** Narrow viewports: slide-over hosts panel; md+ sidebar stays visible */
@@ -43,13 +173,36 @@ const showHostForm = ref(false);
const showFolderForm = ref(false); const showFolderForm = ref(false);
const showEditHost = ref(false); const showEditHost = ref(false);
const showAuditLog = ref(false); const showAuditLog = ref(false);
const showApiKeys = ref(false);
const showSftpPanel = ref(false);
function toggleSftp() {
showSftpPanel.value = !showSftpPanel.value;
}
const auditLoading = ref(false); const auditLoading = ref(false);
const auditErr = ref(""); const auditErr = ref("");
const auditRows = ref<ConnectionAuditRow[]>([]); const auditRows = ref<ConnectionAuditRow[]>([]);
const auditShowAll = ref(false); const auditShowAll = ref(false);
const apiKeysLoading = ref(false);
const apiKeysErr = ref("");
const apiKeyRows = ref<ApiKeyRow[]>([]);
const apiKeyScopes = ref<ApiKeyScopeDef[]>([]);
const apiKeyForm = ref({
label: "",
scopes: [] as string[],
expires_at: "",
});
const apiKeyCreating = ref(false);
const apiKeyCreateErr = ref("");
const createdApiKey = ref("");
const deleteIdentityErr = ref(""); const deleteIdentityErr = ref("");
const deleteIdentityErrId = ref<number | null>(null); const deleteIdentityErrId = ref<number | null>(null);
const newFolderLabel = ref(""); const newFolderLabel = ref("");
const showEditFolder = ref(false);
const editFolderForm = ref({
id: 0,
label: "",
parent_id: null as number | null,
});
const identityForm = ref({ const identityForm = ref({
label: "", label: "",
auth_type: "password" as "password" | "publickey", auth_type: "password" as "password" | "publickey",
@@ -71,6 +224,7 @@ const hostForm = ref({
label: "", label: "",
hostname: "", hostname: "",
port: 22, port: 22,
tags: "",
use_inline_identity: false, use_inline_identity: false,
identity_id: 0 as number, identity_id: 0 as number,
auth_type: "password" as "password" | "publickey", auth_type: "password" as "password" | "publickey",
@@ -85,6 +239,7 @@ const editHostForm = ref({
label: "", label: "",
hostname: "", hostname: "",
port: 22, port: 22,
tags: "",
use_inline_identity: false, use_inline_identity: false,
identity_id: 0, identity_id: 0,
auth_type: "password" as "password" | "publickey", auth_type: "password" as "password" | "publickey",
@@ -112,6 +267,18 @@ function folderOptionLabel(id: number | null): string {
return parts.join(" / ") || `#${id}`; return parts.join(" / ") || `#${id}`;
} }
function parseTagsInput(raw: string): string[] {
const seen = new Set<string>();
const tags: string[] = [];
for (const part of raw.split(",")) {
const name = part.trim().toLowerCase().replace(/\s+/g, "");
if (!name || seen.has(name)) continue;
seen.add(name);
tags.push(name);
}
return tags.sort();
}
function getSortedBrowseHosts(): HostRow[] { function getSortedBrowseHosts(): HostRow[] {
const hosts = [...browseHosts.value]; const hosts = [...browseHosts.value];
if (hostSortOrder.value === "last_connected") { if (hostSortOrder.value === "last_connected") {
@@ -151,12 +318,19 @@ function goToFolder(id: number | null) {
void refreshBrowse(); void refreshBrowse();
} }
function searchByTag(tag: string) {
searchQuery.value = `tag:${tag}`;
void refreshBrowse();
}
async function refreshData() { async function refreshData() {
loadErr.value = ""; loadErr.value = "";
try { try {
identities.value = await api.listIdentities(); identities.value = await api.listIdentities();
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();
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;
} }
@@ -170,6 +344,9 @@ onMounted(async () => {
try { try {
const m = await api.me(); const m = await api.me();
loggedIn.value = m.logged_in; loggedIn.value = m.logged_in;
if (m.app_version) {
appVersion.value = m.app_version;
}
if (loggedIn.value) await refreshData(); if (loggedIn.value) await refreshData();
} catch { } catch {
loggedIn.value = false; loggedIn.value = false;
@@ -186,7 +363,8 @@ async function onLoggedIn() {
async function logout() { async function logout() {
await api.logout(); await api.logout();
tabs.value = []; tabs.value = [];
activeTabId.value = null; activePanes.value = [];
allHosts.value = [];
loggedIn.value = false; loggedIn.value = false;
} }
@@ -234,10 +412,125 @@ async function loadAllAuditLog() {
} }
} }
function resetApiKeyForm() {
apiKeyForm.value = {
label: "",
scopes: apiKeyScopes.value.some((s) => s.id === "read:hosts")
? ["read:hosts"]
: [],
expires_at: "",
};
apiKeyCreateErr.value = "";
createdApiKey.value = "";
}
function toggleApiKeyScope(scopeId: string) {
const scopes = apiKeyForm.value.scopes;
const idx = scopes.indexOf(scopeId);
if (idx >= 0) {
apiKeyForm.value.scopes = scopes.filter((s) => s !== scopeId);
} else {
apiKeyForm.value.scopes = [...scopes, scopeId];
}
}
async function openApiKeys() {
showApiKeys.value = true;
apiKeysLoading.value = true;
apiKeysErr.value = "";
resetApiKeyForm();
try {
if (!apiKeyScopes.value.length) {
apiKeyScopes.value = await api.listApiKeyScopes();
resetApiKeyForm();
}
apiKeyRows.value = await api.listApiKeys();
} catch (e) {
apiKeysErr.value = e instanceof Error ? e.message : "Failed to load API keys";
} finally {
apiKeysLoading.value = false;
}
}
async function refreshApiKeys() {
apiKeysLoading.value = true;
apiKeysErr.value = "";
try {
apiKeyRows.value = await api.listApiKeys();
} catch (e) {
apiKeysErr.value = e instanceof Error ? e.message : "Failed to load API keys";
} finally {
apiKeysLoading.value = false;
}
}
async function submitApiKey() {
apiKeyCreating.value = true;
apiKeyCreateErr.value = "";
createdApiKey.value = "";
try {
const body: {
label: string;
scopes: string[];
expires_at?: string | null;
} = {
label: apiKeyForm.value.label.trim(),
scopes: apiKeyForm.value.scopes,
};
if (apiKeyForm.value.expires_at) {
body.expires_at = new Date(apiKeyForm.value.expires_at).toISOString();
}
const created = await api.createApiKey(body);
createdApiKey.value = created.key;
apiKeyForm.value = {
label: "",
scopes: apiKeyScopes.value.some((s) => s.id === "read:hosts")
? ["read:hosts"]
: [],
expires_at: "",
};
await refreshApiKeys();
} catch (e) {
apiKeyCreateErr.value = e instanceof Error ? e.message : "Failed to create API key";
} finally {
apiKeyCreating.value = false;
}
}
async function deleteApiKey(id: number, label: string) {
if (!confirm(`Delete API key "${label}"? This cannot be undone.`)) return;
apiKeysErr.value = "";
try {
await api.deleteApiKey(id);
await refreshApiKeys();
} catch (e) {
apiKeysErr.value = e instanceof Error ? e.message : "Failed to delete API key";
}
}
async function copyCreatedApiKey() {
if (!createdApiKey.value) return;
try {
await navigator.clipboard.writeText(createdApiKey.value);
} catch {
/* clipboard may be unavailable */
}
}
function apiKeyStatus(row: ApiKeyRow): string {
if (row.revoked_at) return "Revoked";
if (row.expired) return "Expired";
return "Active";
}
function fmtScopes(scopes: string[]): string {
return scopes.join(", ");
}
function openTab(h: HostRow) { function openTab(h: HostRow) {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
tabs.value.push({ id, hostId: h.id, label: h.label }); tabs.value.push({ id, hostId: h.id, label: h.label });
activeTabId.value = id; activePanes.value = [id];
if (window.matchMedia("(max-width: 767px)").matches) { if (window.matchMedia("(max-width: 767px)").matches) {
sidebarOpen.value = false; sidebarOpen.value = false;
} }
@@ -249,8 +542,9 @@ function toggleSidebar() {
function closeTab(id: string) { function closeTab(id: string) {
tabs.value = tabs.value.filter((t) => t.id !== id); tabs.value = tabs.value.filter((t) => t.id !== id);
if (activeTabId.value === id) { activePanes.value = activePanes.value.filter((p) => p !== id);
activeTabId.value = tabs.value.length ? tabs.value[tabs.value.length - 1].id : null; if (activePanes.value.length === 0 && tabs.value.length) {
activePanes.value = [tabs.value[tabs.value.length - 1].id];
} }
} }
@@ -349,12 +643,18 @@ async function submitHost() {
body.identity_id = f.identity_id; body.identity_id = f.identity_id;
} }
const tags = parseTagsInput(f.tags);
if (tags.length) {
body.tags = tags;
}
await api.createHost(body); await api.createHost(body);
showHostForm.value = false; showHostForm.value = false;
hostForm.value = { hostForm.value = {
label: "", label: "",
hostname: "", hostname: "",
port: 22, port: 22,
tags: "",
use_inline_identity: false, use_inline_identity: false,
identity_id: hostForm.value.identity_id, identity_id: hostForm.value.identity_id,
auth_type: "password", auth_type: "password",
@@ -376,6 +676,25 @@ async function submitFolder() {
await refreshBrowse(); await refreshBrowse();
} }
function openEditFolder(f: FolderRow) {
editFolderForm.value = {
id: f.id,
label: f.label,
parent_id: f.parent_id,
};
showEditFolder.value = true;
}
async function submitEditFolder() {
const f = editFolderForm.value;
await api.updateFolder(f.id, {
label: f.label.trim(),
parent_id: f.parent_id,
});
showEditFolder.value = false;
await refreshBrowse();
}
function openEditHost(h: HostRow) { function openEditHost(h: HostRow) {
const hasInlineIdentity = h.identity_id === null; const hasInlineIdentity = h.identity_id === null;
editHostForm.value = { editHostForm.value = {
@@ -383,6 +702,7 @@ function openEditHost(h: HostRow) {
label: h.label, label: h.label,
hostname: h.hostname, hostname: h.hostname,
port: h.port, port: h.port,
tags: (h.tags || []).join(", "),
use_inline_identity: hasInlineIdentity, use_inline_identity: hasInlineIdentity,
identity_id: h.identity_id || 0, identity_id: h.identity_id || 0,
auth_type: (h.identity_auth_type as "password" | "publickey") || "password", auth_type: (h.identity_auth_type as "password" | "publickey") || "password",
@@ -422,6 +742,8 @@ async function submitEditHost() {
body.identity_id = f.identity_id; body.identity_id = f.identity_id;
} }
body.tags = parseTagsInput(f.tags);
await api.patchHost(f.id, body); await api.patchHost(f.id, body);
showEditHost.value = false; showEditHost.value = false;
allHosts.value = await api.listHosts(); allHosts.value = await api.listHosts();
@@ -447,10 +769,9 @@ async function deleteHostRow(id: number) {
await api.deleteHost(id); await api.deleteHost(id);
allHosts.value = allHosts.value.filter((h) => h.id !== id); allHosts.value = allHosts.value.filter((h) => h.id !== id);
tabs.value = tabs.value.filter((t) => t.hostId !== id); tabs.value = tabs.value.filter((t) => t.hostId !== id);
if (!tabs.value.some((t) => t.id === activeTabId.value)) { activePanes.value = activePanes.value.filter(p => tabs.value.some(t => t.id === p));
activeTabId.value = tabs.value.length if (activePanes.value.length === 0 && tabs.value.length) {
? tabs.value[tabs.value.length - 1].id activePanes.value = [tabs.value[tabs.value.length - 1].id];
: null;
} }
await refreshBrowse(); await refreshBrowse();
} }
@@ -502,12 +823,103 @@ async function deleteIdentityRow(id: number) {
/> />
</svg> </svg>
</button> </button>
<span class="truncate text-sm font-semibold text-white">JDB-NET SSH</span> <div class="flex items-center gap-2 truncate">
<span class="truncate text-sm font-semibold text-white">SSH</span>
<a
href="https://git.jdbnet.co.uk/jamie/ssh"
target="_blank"
rel="noopener noreferrer"
class="truncate text-xs text-slate-400 hover:text-slate-300"
>
{{ appVersion }}
</a>
</div>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <button
v-if="activePanes.length > 1"
type="button" type="button"
class="rounded-lg px-3 py-1.5 text-xs text-slate-400 hover:bg-slate-800 hover:text-white" class="hidden rounded-lg px-3 py-1.5 text-xs md:inline-flex border transition-colors"
:class="broadcastMode ? 'border-accent bg-accent/10 text-accent' : 'border-slate-800 text-slate-400 hover:border-slate-700 hover:text-white'"
@click="broadcastMode = !broadcastMode"
title="Broadcast input to all visible terminals"
>
<span class="flex items-center gap-2">
<Radio class="h-3.5 w-3.5" />
Broadcast
</span>
</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
type="button"
class="hidden rounded-lg px-3 py-1.5 text-xs md:inline-flex"
:class="showSftpPanel ? 'bg-slate-800 text-white' : 'text-slate-400 hover:bg-slate-800 hover:text-white'"
@click="toggleSftp"
>
SFTP
</button>
<button
type="button"
class="hidden rounded-lg px-3 py-1.5 text-xs text-slate-400 hover:bg-slate-800 hover:text-white md:inline-flex"
@click="openApiKeys"
>
API keys
</button>
<button
type="button"
class="hidden rounded-lg px-3 py-1.5 text-xs text-slate-400 hover:bg-slate-800 hover:text-white md:inline-flex"
@click="openAuditLog" @click="openAuditLog"
> >
Connection audit Connection audit
@@ -544,7 +956,7 @@ async function deleteIdentityRow(id: number) {
<input <input
v-model="searchQuery" v-model="searchQuery"
type="search" type="search"
placeholder="Search in this folder…" placeholder="Search…"
class="mt-2 w-full rounded-lg border border-slate-700 bg-surface-overlay px-2 py-1.5 text-xs text-white placeholder:text-slate-500 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent" class="mt-2 w-full rounded-lg border border-slate-700 bg-surface-overlay px-2 py-1.5 text-xs text-white placeholder:text-slate-500 focus:border-accent focus:outline-none focus:ring-1 focus:ring-accent"
@input="onSearchInput" @input="onSearchInput"
> >
@@ -554,7 +966,9 @@ async function deleteIdentityRow(id: number) {
> >
Matches in Matches in
{{ {{
currentFolderId == null searchQuery.trim().toLowerCase().startsWith("tag:")
? "all folders (tag search)"
: currentFolderId == null
? "all folders" ? "all folders"
: "this folder and below" : "this folder and below"
}} }}
@@ -620,13 +1034,24 @@ async function deleteIdentityRow(id: number) {
<span>{{ f.label }}</span> <span>{{ f.label }}</span>
</span> </span>
</button> </button>
<div class="flex gap-1 shrink-0">
<button <button
type="button" type="button"
class="shrink-0 text-[10px] text-red-400/80 hover:underline" class="text-slate-400/70 hover:text-slate-300"
title="Rename folder"
@click="openEditFolder(f)"
>
<Pencil class="h-3 w-3" aria-hidden="true" />
</button>
<button
type="button"
class="text-red-400/70 hover:text-red-400"
title="Delete folder"
@click="deleteFolderRow(f.id)" @click="deleteFolderRow(f.id)"
> >
Del <Trash2 class="h-3 w-3" aria-hidden="true" />
</button> </button>
</div>
</li> </li>
</ul> </ul>
<div v-if="browseHosts.length" class="mb-2 flex items-center justify-between"> <div v-if="browseHosts.length" class="mb-2 flex items-center justify-between">
@@ -661,26 +1086,43 @@ async function deleteIdentityRow(id: number) {
> >
via {{ h.jump_host_label || `#${h.jump_host_id}` }} via {{ h.jump_host_label || `#${h.jump_host_id}` }}
</p> </p>
<div
v-if="h.tags?.length"
class="mt-1 flex flex-wrap gap-1"
>
<button
v-for="tag in h.tags"
:key="tag"
type="button"
class="rounded bg-slate-800 px-1.5 py-0.5 text-[10px] text-slate-400 hover:bg-slate-700 hover:text-accent"
:title="`Search tag:${tag}`"
@click.stop="searchByTag(tag)"
>
{{ tag }}
</button>
</div>
<p <p
v-if="searchActive" v-if="searchActive"
class="mt-0.5 truncate text-[10px] text-slate-600" class="mt-0.5 truncate text-[10px] text-slate-600"
> >
{{ folderOptionLabel(h.folder_id) }} {{ folderOptionLabel(h.folder_id) }}
</p> </p>
<div class="mt-1 flex gap-2"> <div class="mt-1 flex gap-1">
<button <button
type="button" type="button"
class="text-[10px] text-slate-400 hover:text-white hover:underline" class="text-slate-400/70 hover:text-slate-300"
title="Edit host"
@click="openEditHost(h)" @click="openEditHost(h)"
> >
Edit <Pencil class="h-3 w-3" aria-hidden="true" />
</button> </button>
<button <button
type="button" type="button"
class="text-[10px] text-red-400/80 hover:underline" class="text-red-400/70 hover:text-red-400"
title="Delete host"
@click="deleteHostRow(h.id)" @click="deleteHostRow(h.id)"
> >
Remove <Trash2 class="h-3 w-3" aria-hidden="true" />
</button> </button>
</div> </div>
</li> </li>
@@ -720,17 +1162,19 @@ async function deleteIdentityRow(id: number) {
<div class="flex gap-1"> <div class="flex gap-1">
<button <button
type="button" type="button"
class="shrink-0 text-slate-400/70 hover:text-slate-300 hover:underline" class="shrink-0 text-slate-400/70 hover:text-slate-300"
title="Edit identity"
@click="openEditIdentity(i)" @click="openEditIdentity(i)"
> >
Edit <Pencil class="h-3 w-3" aria-hidden="true" />
</button> </button>
<button <button
type="button" type="button"
class="shrink-0 text-red-400/70 hover:underline" class="shrink-0 text-red-400/70 hover:text-red-400"
title="Delete identity"
@click="deleteIdentityRow(i.id)" @click="deleteIdentityRow(i.id)"
> >
× <Trash2 class="h-3 w-3" aria-hidden="true" />
</button> </button>
</div> </div>
</li> </li>
@@ -752,13 +1196,19 @@ async function deleteIdentityRow(id: number) {
v-for="t in tabs" v-for="t in tabs"
:key="t.id" :key="t.id"
type="button" type="button"
class="flex items-center gap-2 rounded-t-lg border border-b-0 px-3 py-2 text-sm" draggable="true"
:class=" @dragstart="onTabDragStart(t.id)"
t.id === activeTabId @dragover.prevent
@drop="onTabDrop(t.id)"
@dragend="onTabDragEnd"
class="flex items-center gap-2 rounded-t-lg border border-b-0 px-3 py-2 text-sm transition-colors"
:class="[
activePanes.includes(t.id)
? 'border-slate-700 bg-surface text-white' ? 'border-slate-700 bg-surface text-white'
: 'border-transparent bg-transparent text-slate-400 hover:text-white' : 'border-transparent bg-transparent text-slate-400 hover:text-white',
" draggedTabId && draggedTabId !== t.id ? 'hover:bg-slate-800/50' : ''
@click="activeTabId = t.id" ]"
@click="activePanes = [t.id]"
> >
{{ t.label }} {{ t.label }}
<span <span
@@ -767,23 +1217,179 @@ async function deleteIdentityRow(id: number) {
>×</span> >×</span>
</button> </button>
</div> </div>
<div class="min-h-0 flex-1 p-2 md:p-3"> <div class="relative min-h-0 flex-1 flex flex-row gap-2 p-2 md:p-3">
<div <div
v-for="t in tabs" v-for="t in tabs"
v-show="t.id === activeTabId" v-show="activePanes.includes(t.id)"
:key="t.id" :key="t.id"
class="h-full min-h-0" class="flex-1 min-w-0 h-full"
> >
<TabContent <TabContent
:ref="(el) => setTabRef(el, t.id)"
:host-id="t.hostId" :host-id="t.hostId"
:visible="t.id === activeTabId" :visible="activePanes.includes(t.id)"
:show-sftp="showSftpPanel"
@broadcast-data="(data: string) => handleBroadcast(data, t.id)"
/> />
</div> </div>
<div
v-if="draggedTabId && activePanes.length === 1 && !activePanes.includes(draggedTabId)"
class="absolute inset-y-2 right-2 md:inset-y-3 md:right-3 w-[calc(50%-0.25rem)] z-50 flex items-center justify-center rounded-lg border-2 border-dashed border-accent bg-accent/10 backdrop-blur-[2px] transition-all"
@dragover.prevent
@drop="onSplitDrop"
>
<span class="font-medium text-accent">Drop to split side-by-side</span>
</div>
</div> </div>
</template> </template>
</main> </main>
</div> </div>
<SnippetForm
v-if="showSnippetForm"
:snippet="editingSnippet"
@save="saveSnippet"
@cancel="showSnippetForm = false"
/>
<div
v-if="showApiKeys"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
>
<div
class="max-h-[90vh] w-full max-w-4xl overflow-auto rounded-xl border border-slate-800 bg-surface-raised p-6 shadow-xl"
>
<div class="flex items-center justify-between gap-3">
<h2 class="text-lg font-semibold text-white">API keys</h2>
<button
type="button"
class="rounded-lg px-3 py-1.5 text-xs text-slate-400 hover:bg-slate-800 hover:text-white"
@click="showApiKeys = false"
>
Close
</button>
</div>
<p class="mt-1 text-xs text-slate-500">
Create keys for external systems. Send
<code class="text-slate-400">Authorization: Bearer &lt;key&gt;</code>
on API requests. For WebSocket terminals, append
<code class="text-slate-400">?token=&lt;key&gt;</code>.
</p>
<form class="mt-5 rounded-lg border border-slate-800 bg-surface-overlay/40 p-4" @submit.prevent="submitApiKey">
<h3 class="text-sm font-medium text-white">Create key</h3>
<label class="mt-3 block text-xs uppercase text-slate-500">Label</label>
<input
v-model="apiKeyForm.label"
required
maxlength="255"
placeholder="CI deploy, monitoring, "
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
/>
<p class="mt-3 text-xs uppercase text-slate-500">Scopes</p>
<div class="mt-2 space-y-2">
<label
v-for="scope in apiKeyScopes"
:key="scope.id"
class="flex cursor-pointer items-start gap-2 rounded border border-slate-800 px-3 py-2 hover:border-slate-700"
>
<input
type="checkbox"
class="mt-0.5"
:checked="apiKeyForm.scopes.includes(scope.id)"
@change="toggleApiKeyScope(scope.id)"
/>
<span>
<span class="block text-sm text-slate-200">{{ scope.label }}</span>
<span class="block text-xs text-slate-500">{{ scope.description }}</span>
</span>
</label>
</div>
<label class="mt-3 block text-xs uppercase text-slate-500">Expiry (optional)</label>
<input
v-model="apiKeyForm.expires_at"
type="datetime-local"
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
/>
<p v-if="apiKeyCreateErr" class="mt-3 text-xs text-red-400">{{ apiKeyCreateErr }}</p>
<div
v-if="createdApiKey"
class="mt-3 rounded border border-amber-900/60 bg-amber-950/30 p-3"
>
<p class="text-xs text-amber-200">
Copy this key now — it will not be shown again.
</p>
<div class="mt-2 flex items-center gap-2">
<code class="min-w-0 flex-1 break-all rounded bg-surface-overlay px-2 py-1 text-[11px] text-slate-200">
{{ createdApiKey }}
</code>
<button
type="button"
class="shrink-0 rounded bg-slate-800 px-2 py-1 text-xs hover:bg-slate-700"
@click="copyCreatedApiKey"
>
Copy
</button>
</div>
</div>
<button
type="submit"
class="mt-4 rounded-lg bg-accent px-3 py-1.5 text-xs font-medium text-slate-950 hover:bg-sky-400 disabled:opacity-50"
:disabled="apiKeyCreating || !apiKeyForm.scopes.length"
>
{{ apiKeyCreating ? "Creating" : "Create key" }}
</button>
</form>
<p v-if="apiKeysErr" class="mt-4 text-xs text-red-400">{{ apiKeysErr }}</p>
<p v-else-if="apiKeysLoading" class="mt-4 text-xs text-slate-400">Loading…</p>
<div v-else class="mt-4 overflow-x-auto">
<table class="min-w-full text-left text-xs">
<thead class="text-slate-500">
<tr class="border-b border-slate-800">
<th class="px-2 py-2 font-medium">Label</th>
<th class="px-2 py-2 font-medium">Prefix</th>
<th class="px-2 py-2 font-medium">Scopes</th>
<th class="px-2 py-2 font-medium">Expires</th>
<th class="px-2 py-2 font-medium">Last used</th>
<th class="px-2 py-2 font-medium">Status</th>
<th class="px-2 py-2 font-medium"></th>
</tr>
</thead>
<tbody>
<tr
v-for="row in apiKeyRows"
:key="row.id"
class="border-b border-slate-900/80 text-slate-300"
>
<td class="px-2 py-2">{{ row.label }}</td>
<td class="px-2 py-2 font-mono text-[11px]">{{ row.key_prefix }}…</td>
<td class="px-2 py-2">{{ fmtScopes(row.scopes) }}</td>
<td class="px-2 py-2">{{ row.expires_at ? fmtDate(row.expires_at) : "Never" }}</td>
<td class="px-2 py-2">{{ row.last_used_at ? fmtDate(row.last_used_at) : "Never" }}</td>
<td class="px-2 py-2">{{ apiKeyStatus(row) }}</td>
<td class="px-2 py-2 text-right">
<button
type="button"
class="rounded px-2 py-1 text-red-400 hover:bg-slate-800"
@click="deleteApiKey(row.id, row.label)"
>
Delete
</button>
</td>
</tr>
<tr v-if="!apiKeyRows.length">
<td class="px-2 py-4 text-center text-slate-500" colspan="7">
No API keys yet.
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div <div
v-if="showAuditLog" v-if="showAuditLog"
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"
@@ -1036,6 +1642,12 @@ async function deleteIdentityRow(id: number) {
max="65535" max="65535"
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm" class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
/> />
<label class="mt-3 block text-xs uppercase text-slate-500">Tags</label>
<TagInput v-model="hostForm.tags" :suggestions="allTags" />
<p class="mt-1 text-[10px] text-slate-500">
Comma-separated. Search with
<code class="text-slate-400">tag:name</code>.
</p>
<label class="mt-3 block text-xs uppercase text-slate-500">Credentials</label> <label class="mt-3 block text-xs uppercase text-slate-500">Credentials</label>
<div class="mt-1 flex gap-2"> <div class="mt-1 flex gap-2">
<label class="flex items-center gap-2 cursor-pointer"> <label class="flex items-center gap-2 cursor-pointer">
@@ -1185,6 +1797,39 @@ async function deleteIdentityRow(id: number) {
</form> </form>
</div> </div>
<div
v-if="showEditFolder"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
>
<form
class="w-full max-w-sm rounded-xl border border-slate-800 bg-surface-raised p-6 shadow-xl"
@submit.prevent="submitEditFolder"
>
<h2 class="text-lg font-semibold text-white">Rename folder</h2>
<label class="mt-4 block text-xs uppercase text-slate-500">Name</label>
<input
v-model="editFolderForm.label"
required
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
>
<div class="mt-6 flex justify-end gap-2">
<button
type="button"
class="rounded-lg px-4 py-2 text-sm text-slate-400 hover:bg-slate-800"
@click="showEditFolder = false"
>
Cancel
</button>
<button
type="submit"
class="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-slate-950"
>
Save
</button>
</div>
</form>
</div>
<div <div
v-if="showEditHost" v-if="showEditHost"
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"
@@ -1214,6 +1859,12 @@ async function deleteIdentityRow(id: number) {
max="65535" max="65535"
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm" class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
> >
<label class="mt-3 block text-xs uppercase text-slate-500">Tags</label>
<TagInput v-model="editHostForm.tags" :suggestions="allTags" />
<p class="mt-1 text-[10px] text-slate-500">
Comma-separated. Search with
<code class="text-slate-400">tag:name</code>.
</p>
<label class="mt-3 block text-xs uppercase text-slate-500">Folder</label> <label class="mt-3 block text-xs uppercase text-slate-500">Folder</label>
<select <select
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm" class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
+129 -1
View File
@@ -21,7 +21,7 @@ function browseParams(folderId: number | null, q: string): string {
} }
export const api = { export const api = {
async me(): Promise<{ logged_in: boolean }> { async me(): Promise<{ logged_in: boolean; app_version?: string }> {
const res = await fetch("/api/me", { credentials: "include" }); const res = await fetch("/api/me", { credentials: "include" });
return handle(res); return handle(res);
}, },
@@ -65,6 +65,12 @@ export const api = {
return d.items; return d.items;
}, },
async listTags(): Promise<string[]> {
const res = await fetch("/api/tags", { credentials: "include" });
const d = await handle<{ items: string[] }>(res);
return d.items;
},
async listFoldersFlat(): Promise<FolderRow[]> { async listFoldersFlat(): Promise<FolderRow[]> {
const res = await fetch("/api/folders", { credentials: "include" }); const res = await fetch("/api/folders", { credentials: "include" });
const d = await handle<{ items: FolderRow[] }>(res); const d = await handle<{ items: FolderRow[] }>(res);
@@ -92,6 +98,22 @@ export const api = {
await handle(res); await handle(res);
}, },
async updateFolder(
id: number,
body: {
label?: string;
parent_id?: number | null;
},
): Promise<void> {
const res = await fetch(`/api/folders/${id}`, {
method: "PATCH",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify(body),
});
await handle(res);
},
async listIdentities(): Promise<IdentityRow[]> { async listIdentities(): Promise<IdentityRow[]> {
const res = await fetch("/api/identities", { credentials: "include" }); const res = await fetch("/api/identities", { credentials: "include" });
const d = await handle<{ items: IdentityRow[] }>(res); const d = await handle<{ items: IdentityRow[] }>(res);
@@ -166,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) {
@@ -178,6 +237,40 @@ export const api = {
return d.items; return d.items;
}, },
async listApiKeyScopes(): Promise<ApiKeyScopeDef[]> {
const res = await fetch("/api/api-keys/scopes", { credentials: "include" });
const d = await handle<{ items: ApiKeyScopeDef[] }>(res);
return d.items;
},
async listApiKeys(): Promise<ApiKeyRow[]> {
const res = await fetch("/api/api-keys", { credentials: "include" });
const d = await handle<{ items: ApiKeyRow[] }>(res);
return d.items;
},
async createApiKey(body: {
label: string;
scopes: string[];
expires_at?: string | null;
}): Promise<CreateApiKeyResponse> {
const res = await fetch("/api/api-keys", {
method: "POST",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify(body),
});
return handle(res);
},
async deleteApiKey(id: number): Promise<void> {
const res = await fetch(`/api/api-keys/${id}`, {
method: "DELETE",
credentials: "include",
});
await handle(res);
},
async sftpList( async sftpList(
connId: string, connId: string,
path: string, path: string,
@@ -262,6 +355,7 @@ export interface HostRow {
identity_auth_type: string; identity_auth_type: string;
folder_label?: string | null; folder_label?: string | null;
last_connected_at?: string | null; last_connected_at?: string | null;
tags?: string[];
} }
export interface IdentityRow { export interface IdentityRow {
@@ -270,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;
@@ -288,3 +388,31 @@ export interface ConnectionAuditRow {
ended_at: string | null; ended_at: string | null;
duration_seconds: number | null; duration_seconds: number | null;
} }
export interface ApiKeyScopeDef {
id: string;
label: string;
description: string;
}
export interface ApiKeyRow {
id: number;
label: string;
key_prefix: string;
scopes: string[];
expires_at: string | null;
last_used_at: string | null;
revoked_at: string | null;
created_at: string;
expired: boolean;
active: boolean;
}
export interface CreateApiKeyResponse {
id: number;
label: string;
key_prefix: string;
scopes: string[];
expires_at: string | null;
key: string;
}
+1 -1
View File
@@ -31,7 +31,7 @@ async function submit() {
class="w-full max-w-md rounded-xl border border-slate-800 bg-surface-raised p-8 shadow-xl" class="w-full max-w-md rounded-xl border border-slate-800 bg-surface-raised p-8 shadow-xl"
> >
<h1 class="font-sans text-2xl font-semibold tracking-tight text-white"> <h1 class="font-sans text-2xl font-semibold tracking-tight text-white">
JDB-NET SSH SSH
</h1> </h1>
<p class="mt-1 text-sm text-slate-400"> <p class="mt-1 text-sm text-slate-400">
Sign in to manage connections and open terminals. Sign in to manage connections and open terminals.
+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>
+61 -31
View File
@@ -14,8 +14,21 @@ import SftpPanel from "./SftpPanel.vue";
const props = defineProps<{ const props = defineProps<{
hostId: number; hostId: number;
visible: boolean; visible: boolean;
showSftp: boolean;
}>(); }>();
const emit = defineEmits<{
(e: 'broadcast-data', data: string): void;
}>();
defineExpose({
sendData: (data: string) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(new TextEncoder().encode(data));
}
}
});
const termEl = ref<HTMLElement | null>(null); const termEl = ref<HTMLElement | null>(null);
const status = ref("Connecting…"); const status = ref("Connecting…");
const connId = ref<string | null>(null); const connId = ref<string | null>(null);
@@ -24,7 +37,7 @@ let ws: WebSocket | null = null;
let term: Terminal | null = null; let term: Terminal | null = null;
let fit: FitAddon | null = null; let fit: FitAddon | null = null;
let ro: ResizeObserver | null = null; let ro: ResizeObserver | null = null;
let pingInterval: NodeJS.Timeout | null = null; let visibilityHandler: (() => void) | null = null;
function wsUrl(hostId: number): string { function wsUrl(hostId: number): string {
const proto = location.protocol === "https:" ? "wss:" : "ws:"; const proto = location.protocol === "https:" ? "wss:" : "ws:";
@@ -37,6 +50,12 @@ function sendResize() {
ws.send(JSON.stringify({ type: "resize", ...dims })); ws.send(JSON.stringify({ type: "resize", ...dims }));
} }
function sendPing() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ping" }));
}
}
function fitAndResize() { function fitAndResize() {
if (!fit || !term || !props.visible) return; if (!fit || !term || !props.visible) return;
try { try {
@@ -47,18 +66,38 @@ function fitAndResize() {
} }
} }
function isControlMessage(raw: string): boolean {
try {
const o = JSON.parse(raw) as { type?: string; conn_id?: string };
if (o.type === "ready" && o.conn_id) {
connId.value = o.conn_id;
status.value = "";
fitAndResize();
term?.focus();
return true;
}
if (o.type === "keepalive" || o.type === "pong") {
return true;
}
} catch {
/* not JSON control traffic */
}
return false;
}
onMounted(async () => { onMounted(async () => {
await nextTick(); await nextTick();
if (!termEl.value) return; if (!termEl.value) return;
term = new Terminal({ term = new Terminal({
cursorBlink: true, cursorBlink: true,
fontFamily: "IBM Plex Mono, monospace", fontFamily: "DM Mono, ui-monospace, monospace",
fontSize: 14, fontSize: 14,
theme: { theme: {
background: "#0a0e12", background: "#0d1117",
foreground: "#e2e8f0", foreground: "#e6edf3",
cursor: "#3d9aed", cursor: "#1ebe8a",
selectionBackground: "rgba(30, 190, 138, 0.3)",
}, },
}); });
fit = new FitAddon(); fit = new FitAddon();
@@ -70,6 +109,7 @@ onMounted(async () => {
if (ws && ws.readyState === WebSocket.OPEN) { if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(new TextEncoder().encode(data)); ws.send(new TextEncoder().encode(data));
} }
emit("broadcast-data", data);
}); });
term.onResize(({ cols, rows }) => { term.onResize(({ cols, rows }) => {
@@ -87,29 +127,12 @@ onMounted(async () => {
ws.onopen = () => { ws.onopen = () => {
status.value = "Handshaking…"; status.value = "Handshaking…";
sendResize(); sendResize();
// Send ping every 60 seconds to keep connection alive
pingInterval = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ping" }));
}
}, 60000);
}; };
ws.onmessage = (ev) => { ws.onmessage = (ev) => {
if (!term) return; if (!term) return;
if (typeof ev.data === "string") { if (typeof ev.data === "string") {
try { if (isControlMessage(ev.data)) return;
const o = JSON.parse(ev.data) as { type?: string; conn_id?: string };
if (o.type === "ready" && o.conn_id) {
connId.value = o.conn_id;
status.value = "";
fitAndResize();
term.focus();
return;
}
} catch {
/* fall through */
}
term.write(ev.data); term.write(ev.data);
return; return;
} }
@@ -122,22 +145,26 @@ onMounted(async () => {
}; };
ws.onclose = () => { ws.onclose = () => {
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
if (!connId.value) { if (!connId.value) {
status.value = "Disconnected"; status.value = "Disconnected";
} else { } else {
status.value = "Session ended"; status.value = "Session ended";
} }
}; };
visibilityHandler = () => {
if (document.visibilityState === "visible") {
sendPing();
fitAndResize();
}
};
document.addEventListener("visibilitychange", visibilityHandler);
}); });
onUnmounted(() => { onUnmounted(() => {
if (pingInterval) { if (visibilityHandler) {
clearInterval(pingInterval); document.removeEventListener("visibilitychange", visibilityHandler);
pingInterval = null; visibilityHandler = null;
} }
ro?.disconnect(); ro?.disconnect();
ro = null; ro = null;
@@ -155,6 +182,7 @@ watch(
await nextTick(); await nextTick();
fitAndResize(); fitAndResize();
term?.focus(); term?.focus();
sendPing();
} }
}, },
); );
@@ -171,9 +199,10 @@ watch(
</div> </div>
<div <div
ref="termEl" ref="termEl"
class="h-full min-h-[320px] rounded-lg border border-slate-800 bg-[#0a0e12] p-1" class="h-full min-h-[320px] rounded-lg border border-slate-800 bg-[#0d1117] p-1"
/> />
</div> </div>
<template v-if="showSftp">
<div <div
v-if="connId" v-if="connId"
class="hidden w-80 shrink-0 flex-col border-l border-slate-800 md:flex" class="hidden w-80 shrink-0 flex-col border-l border-slate-800 md:flex"
@@ -186,5 +215,6 @@ watch(
> >
SFTP unlocks when the shell session is ready. SFTP unlocks when the shell session is ready.
</div> </div>
</template>
</div> </div>
</template> </template>
+97
View File
@@ -0,0 +1,97 @@
<script setup lang="ts">
import { computed, ref } from "vue";
const props = defineProps<{
modelValue: string;
suggestions: string[];
}>();
const emit = defineEmits<{
"update:modelValue": [value: string];
}>();
const focused = ref(false);
function normalizeTag(raw: string): string {
return raw.trim().toLowerCase().replace(/\s+/g, "");
}
function selectedTags(): Set<string> {
return new Set(parseTagsInput(props.modelValue));
}
function parseTagsInput(raw: string): string[] {
const seen = new Set<string>();
const tags: string[] = [];
for (const part of raw.split(",")) {
const name = normalizeTag(part);
if (!name || seen.has(name)) continue;
seen.add(name);
tags.push(name);
}
return tags;
}
function currentPartial(): string {
const val = props.modelValue;
const idx = val.lastIndexOf(",");
return normalizeTag(idx >= 0 ? val.slice(idx + 1) : val);
}
const filteredSuggestions = computed(() => {
const partial = currentPartial();
const selected = selectedTags();
return props.suggestions
.filter((tag) => {
if (selected.has(tag)) return false;
if (!partial) return true;
return tag.includes(partial);
})
.slice(0, 8);
});
const showSuggestions = computed(
() => focused.value && filteredSuggestions.value.length > 0,
);
function applySuggestion(tag: string) {
const val = props.modelValue;
const idx = val.lastIndexOf(",");
const prefix = idx >= 0 ? `${val.slice(0, idx + 1)} ` : "";
emit("update:modelValue", `${prefix}${tag}, `);
}
function onBlur() {
window.setTimeout(() => {
focused.value = false;
}, 150);
}
</script>
<template>
<div class="relative">
<input
:value="modelValue"
placeholder="buildagents, prod"
autocomplete="off"
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
@focus="focused = true"
@blur="onBlur"
/>
<ul
v-if="showSuggestions"
class="absolute z-20 mt-1 max-h-40 w-full overflow-auto rounded-lg border border-slate-700 bg-surface-raised py-1 shadow-lg"
>
<li v-for="tag in filteredSuggestions" :key="tag">
<button
type="button"
class="block w-full px-3 py-1.5 text-left text-sm text-slate-200 hover:bg-slate-800"
@mousedown.prevent="applySuggestion(tag)"
>
{{ tag }}
</button>
</li>
</ul>
</div>
</template>
+17 -7
View File
@@ -5,18 +5,28 @@ export default {
extend: { extend: {
colors: { colors: {
surface: { surface: {
DEFAULT: "#0f1419", DEFAULT: "#0d1117",
raised: "#151c24", raised: "#161b22",
overlay: "#1a232e", overlay: "#21262d",
}, },
accent: { accent: {
DEFAULT: "#3d9aed", DEFAULT: "#1ebe8a",
muted: "#2a6fa3", muted: "#16966b",
},
slate: {
200: "#e6edf3",
300: "#c9d1d9",
400: "#8b949e",
500: "#6e7681",
600: "#484f58",
700: "#30363d",
800: "#21262d",
900: "#161b22",
}, },
}, },
fontFamily: { fontFamily: {
sans: ["IBM Plex Sans", "system-ui", "sans-serif"], sans: ["Space Grotesk", "system-ui", "sans-serif"],
mono: ["IBM Plex Mono", "ui-monospace", "monospace"], mono: ["DM Mono", "ui-monospace", "monospace"],
}, },
}, },
}, },
-6
View File
@@ -1,6 +0,0 @@
{
"name": "ssh",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
Executable
+25
View File
@@ -0,0 +1,25 @@
#!/bin/bash
if [ ! -d "venv" ]; then
echo "Creating virtual environment in 'venv'..."
python3 -m venv venv
source venv/bin/activate
if [ -f "requirements.txt" ]; then
echo "Installing requirements..."
pip install -r requirements.txt
fi
else
source venv/bin/activate
fi
echo "Building frontend..."
(
cd frontend
if [ ! -d "node_modules" ]; then
echo "Installing frontend dependencies..."
npm install
fi
npm run build
)
exec gunicorn --bind 0.0.0.0:5000 --workers 1 --worker-class geventwebsocket.gunicorn.workers.GeventWebSocketWorker app:app --log-level info