@@ -0,0 +1,5 @@
|
|||||||
|
FROM mcr.microsoft.com/devcontainers/python:3.14
|
||||||
|
|
||||||
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
CMD ["sleep", "infinity"]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "Flask Dev Container",
|
||||||
|
"build": {
|
||||||
|
"dockerfile": "Dockerfile"
|
||||||
|
},
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
|
"extensions": [
|
||||||
|
"ms-python.python",
|
||||||
|
"vivaxy.vscode-conventional-commits",
|
||||||
|
"esbenp.prettier-vscode"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"postCreateCommand": "pip install -r requirements.txt",
|
||||||
|
"forwardPorts": [5000],
|
||||||
|
"remoteUser": "vscode"
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.devcontainer
|
||||||
|
.git
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
frontend/node_modules
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
SECRET_KEY=change-me-to-a-long-random-string
|
||||||
|
# Set in production behind HTTPS:
|
||||||
|
# SESSION_COOKIE_SECURE=true
|
||||||
|
# SESSION_PERMANENT=true
|
||||||
|
# SESSION_DAYS=14
|
||||||
|
|
||||||
|
# Web operator login (single user)
|
||||||
|
WEBAPP_USERNAME=admin
|
||||||
|
# Plain password
|
||||||
|
WEBAPP_PASSWORD=change-me
|
||||||
|
# Or use a Werkzeug hash instead of plain WEBAPP_PASSWORD:
|
||||||
|
# WEBAPP_PASSWORD_HASH=pbkdf2:sha256:600000$...
|
||||||
|
|
||||||
|
# Encrypts SSH passwords and keys at rest (any string; hashed to Fernet key internally)
|
||||||
|
CREDENTIALS_ENCRYPTION_KEY=change-me-to-a-long-secret
|
||||||
|
|
||||||
|
# MariaDB / MySQL
|
||||||
|
MYSQL_HOST=127.0.0.1
|
||||||
|
MYSQL_PORT=3306
|
||||||
|
MYSQL_USER=sshweb
|
||||||
|
MYSQL_PASSWORD=change-me
|
||||||
|
MYSQL_DATABASE=ssh_web
|
||||||
|
MYSQL_POOL_SIZE=5
|
||||||
|
|
||||||
|
# Max simultaneous SSH sessions from this app instance
|
||||||
|
MAX_CONCURRENT_SSH=32
|
||||||
|
|
||||||
|
# Paramiko SSH keepalive interval (seconds); set 0 to disable.
|
||||||
|
SSH_KEEPALIVE_INTERVAL=30
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main ]
|
||||||
|
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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
__pycache__/
|
||||||
|
.env
|
||||||
|
frontend/node_modules/
|
||||||
|
static/dist/
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
FROM node:22-bookworm-slim AS frontend
|
||||||
|
WORKDIR /build
|
||||||
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM python:3.14-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY . /app
|
||||||
|
COPY --from=frontend /static/dist /app/static/dist
|
||||||
|
ENV GEVENT_MONKEY_PATCH=1
|
||||||
|
EXPOSE 5000
|
||||||
|
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "1", "--worker-class", "geventwebsocket.gunicorn.workers.GeventWebSocketWorker", "app:app", "--log-level", "warning"]
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link
|
||||||
|
rel="icon"
|
||||||
|
type="image/png"
|
||||||
|
href="https://assets.jdbnet.co.uk/ssh.png"
|
||||||
|
/>
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<meta name="theme-color" content="#0f1419" />
|
||||||
|
<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.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<title>JDB-NET SSH</title>
|
||||||
|
</head>
|
||||||
|
<body class="bg-surface text-slate-200 antialiased">
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2468
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "ssh-web-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@xterm/xterm": "^5.5.0",
|
||||||
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
|
"pinia": "^2.2.6",
|
||||||
|
"vue": "^3.5.13"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"tailwindcss": "^3.4.16",
|
||||||
|
"typescript": "~5.6.3",
|
||||||
|
"vite": "^6.0.3",
|
||||||
|
"vue-tsc": "^2.1.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,958 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from "vue";
|
||||||
|
import {
|
||||||
|
api,
|
||||||
|
type HostRow,
|
||||||
|
type IdentityRow,
|
||||||
|
type FolderRow,
|
||||||
|
type ConnectionAuditRow,
|
||||||
|
} from "@/api";
|
||||||
|
import LoginForm from "@/components/LoginForm.vue";
|
||||||
|
import TabContent from "@/components/TabContent.vue";
|
||||||
|
|
||||||
|
interface TabItem {
|
||||||
|
id: string;
|
||||||
|
hostId: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loggedIn = ref(false);
|
||||||
|
const checking = ref(true);
|
||||||
|
const identities = ref<IdentityRow[]>([]);
|
||||||
|
const allHosts = ref<HostRow[]>([]);
|
||||||
|
const allFolders = ref<FolderRow[]>([]);
|
||||||
|
const browseFolders = ref<FolderRow[]>([]);
|
||||||
|
const browseHosts = ref<HostRow[]>([]);
|
||||||
|
const breadcrumb = ref<{ id: number; label: string }[]>([]);
|
||||||
|
const searchActive = ref(false);
|
||||||
|
const currentFolderId = ref<number | null>(null);
|
||||||
|
const searchQuery = ref("");
|
||||||
|
const tabs = ref<TabItem[]>([]);
|
||||||
|
const activeTabId = ref<string | null>(null);
|
||||||
|
const loadErr = ref("");
|
||||||
|
/** Narrow viewports: slide-over hosts panel; md+ sidebar stays visible */
|
||||||
|
const sidebarOpen = ref(true);
|
||||||
|
|
||||||
|
let searchDebounceTimer = 0;
|
||||||
|
|
||||||
|
const showIdentityForm = ref(false);
|
||||||
|
const showHostForm = ref(false);
|
||||||
|
const showFolderForm = ref(false);
|
||||||
|
const showEditHost = ref(false);
|
||||||
|
const showAuditLog = ref(false);
|
||||||
|
const auditLoading = ref(false);
|
||||||
|
const auditErr = ref("");
|
||||||
|
const auditRows = ref<ConnectionAuditRow[]>([]);
|
||||||
|
const newFolderLabel = ref("");
|
||||||
|
const identityForm = ref({
|
||||||
|
label: "",
|
||||||
|
auth_type: "password" as "password" | "publickey",
|
||||||
|
ssh_username: "",
|
||||||
|
password: "",
|
||||||
|
private_key: "",
|
||||||
|
key_passphrase: "",
|
||||||
|
});
|
||||||
|
const hostForm = ref({
|
||||||
|
label: "",
|
||||||
|
hostname: "",
|
||||||
|
port: 22,
|
||||||
|
identity_id: 0 as number,
|
||||||
|
jump_host_id: null as number | null,
|
||||||
|
});
|
||||||
|
const editHostForm = ref({
|
||||||
|
id: 0,
|
||||||
|
label: "",
|
||||||
|
hostname: "",
|
||||||
|
port: 22,
|
||||||
|
identity_id: 0,
|
||||||
|
folder_id: null as number | null,
|
||||||
|
jump_host_id: null as number | null,
|
||||||
|
});
|
||||||
|
|
||||||
|
function folderOptionLabel(id: number | null): string {
|
||||||
|
if (id == null) return "(root)";
|
||||||
|
const byId = new Map(allFolders.value.map((f) => [f.id, f]));
|
||||||
|
const parts: string[] = [];
|
||||||
|
let cur: number | null | undefined = id;
|
||||||
|
const guard = new Set<number>();
|
||||||
|
while (cur != null && !guard.has(cur)) {
|
||||||
|
guard.add(cur);
|
||||||
|
const f = byId.get(cur);
|
||||||
|
if (!f) break;
|
||||||
|
parts.unshift(f.label);
|
||||||
|
cur = f.parent_id;
|
||||||
|
}
|
||||||
|
return parts.join(" / ") || `#${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshBrowse() {
|
||||||
|
try {
|
||||||
|
const d = await api.browse(currentFolderId.value, searchQuery.value);
|
||||||
|
browseFolders.value = d.folders;
|
||||||
|
browseHosts.value = d.hosts;
|
||||||
|
breadcrumb.value = d.breadcrumb;
|
||||||
|
searchActive.value = d.search_active;
|
||||||
|
} catch (e) {
|
||||||
|
loadErr.value = e instanceof Error ? e.message : "Browse failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearchInput() {
|
||||||
|
window.clearTimeout(searchDebounceTimer);
|
||||||
|
searchDebounceTimer = window.setTimeout(() => {
|
||||||
|
void refreshBrowse();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToFolder(id: number | null) {
|
||||||
|
currentFolderId.value = id;
|
||||||
|
searchQuery.value = "";
|
||||||
|
void refreshBrowse();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshData() {
|
||||||
|
loadErr.value = "";
|
||||||
|
try {
|
||||||
|
identities.value = await api.listIdentities();
|
||||||
|
allHosts.value = await api.listHosts();
|
||||||
|
allFolders.value = await api.listFoldersFlat();
|
||||||
|
if (!hostForm.value.identity_id && identities.value.length) {
|
||||||
|
hostForm.value.identity_id = identities.value[0].id;
|
||||||
|
}
|
||||||
|
await refreshBrowse();
|
||||||
|
} catch (e) {
|
||||||
|
loadErr.value = e instanceof Error ? e.message : "Failed to load data";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const m = await api.me();
|
||||||
|
loggedIn.value = m.logged_in;
|
||||||
|
if (loggedIn.value) await refreshData();
|
||||||
|
} catch {
|
||||||
|
loggedIn.value = false;
|
||||||
|
} finally {
|
||||||
|
checking.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function onLoggedIn() {
|
||||||
|
loggedIn.value = true;
|
||||||
|
await refreshData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
await api.logout();
|
||||||
|
tabs.value = [];
|
||||||
|
activeTabId.value = null;
|
||||||
|
loggedIn.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(ts: string | null): string {
|
||||||
|
if (!ts) return "In progress";
|
||||||
|
const d = new Date(ts);
|
||||||
|
return Number.isNaN(d.getTime()) ? ts : d.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDuration(totalSeconds: number | null): string {
|
||||||
|
if (totalSeconds == null) return "In progress";
|
||||||
|
const s = Math.max(0, Math.floor(totalSeconds));
|
||||||
|
const h = Math.floor(s / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
const sec = s % 60;
|
||||||
|
if (h > 0) return `${h}h ${m}m ${sec}s`;
|
||||||
|
if (m > 0) return `${m}m ${sec}s`;
|
||||||
|
return `${sec}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAuditLog() {
|
||||||
|
showAuditLog.value = true;
|
||||||
|
auditLoading.value = true;
|
||||||
|
auditErr.value = "";
|
||||||
|
try {
|
||||||
|
auditRows.value = await api.listConnectionAudit(250);
|
||||||
|
} catch (e) {
|
||||||
|
auditErr.value = e instanceof Error ? e.message : "Failed to load audit log";
|
||||||
|
} finally {
|
||||||
|
auditLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTab(h: HostRow) {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
tabs.value.push({ id, hostId: h.id, label: h.label });
|
||||||
|
activeTabId.value = id;
|
||||||
|
if (window.matchMedia("(max-width: 767px)").matches) {
|
||||||
|
sidebarOpen.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSidebar() {
|
||||||
|
sidebarOpen.value = !sidebarOpen.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTab(id: string) {
|
||||||
|
tabs.value = tabs.value.filter((t) => t.id !== id);
|
||||||
|
if (activeTabId.value === id) {
|
||||||
|
activeTabId.value = tabs.value.length ? tabs.value[tabs.value.length - 1].id : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitIdentity() {
|
||||||
|
const f = identityForm.value;
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
label: f.label.trim(),
|
||||||
|
auth_type: f.auth_type,
|
||||||
|
ssh_username: f.ssh_username.trim(),
|
||||||
|
};
|
||||||
|
if (f.auth_type === "password") body.password = f.password;
|
||||||
|
else {
|
||||||
|
body.private_key = f.private_key;
|
||||||
|
if (f.key_passphrase) body.key_passphrase = f.key_passphrase;
|
||||||
|
}
|
||||||
|
await api.createIdentity(body);
|
||||||
|
showIdentityForm.value = false;
|
||||||
|
identityForm.value = {
|
||||||
|
label: "",
|
||||||
|
auth_type: "password",
|
||||||
|
ssh_username: "",
|
||||||
|
password: "",
|
||||||
|
private_key: "",
|
||||||
|
key_passphrase: "",
|
||||||
|
};
|
||||||
|
await refreshData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitHost() {
|
||||||
|
const f = hostForm.value;
|
||||||
|
await api.createHost({
|
||||||
|
label: f.label.trim(),
|
||||||
|
hostname: f.hostname.trim(),
|
||||||
|
port: Number(f.port) || 22,
|
||||||
|
identity_id: f.identity_id,
|
||||||
|
folder_id: currentFolderId.value,
|
||||||
|
jump_host_id: f.jump_host_id,
|
||||||
|
});
|
||||||
|
showHostForm.value = false;
|
||||||
|
hostForm.value = {
|
||||||
|
label: "",
|
||||||
|
hostname: "",
|
||||||
|
port: 22,
|
||||||
|
identity_id: hostForm.value.identity_id,
|
||||||
|
jump_host_id: null,
|
||||||
|
};
|
||||||
|
await refreshData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitFolder() {
|
||||||
|
const l = newFolderLabel.value.trim();
|
||||||
|
if (!l) return;
|
||||||
|
await api.createFolder({ label: l, parent_id: currentFolderId.value });
|
||||||
|
newFolderLabel.value = "";
|
||||||
|
showFolderForm.value = false;
|
||||||
|
await refreshBrowse();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditHost(h: HostRow) {
|
||||||
|
editHostForm.value = {
|
||||||
|
id: h.id,
|
||||||
|
label: h.label,
|
||||||
|
hostname: h.hostname,
|
||||||
|
port: h.port,
|
||||||
|
identity_id: h.identity_id,
|
||||||
|
folder_id: h.folder_id,
|
||||||
|
jump_host_id: h.jump_host_id,
|
||||||
|
};
|
||||||
|
showEditHost.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEditHost() {
|
||||||
|
const f = editHostForm.value;
|
||||||
|
await api.patchHost(f.id, {
|
||||||
|
label: f.label.trim(),
|
||||||
|
hostname: f.hostname.trim(),
|
||||||
|
port: Number(f.port) || 22,
|
||||||
|
identity_id: f.identity_id,
|
||||||
|
folder_id: f.folder_id,
|
||||||
|
jump_host_id: f.jump_host_id,
|
||||||
|
});
|
||||||
|
showEditHost.value = false;
|
||||||
|
allHosts.value = await api.listHosts();
|
||||||
|
await refreshBrowse();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFolderRow(id: number) {
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
"Delete this folder and all subfolders? Hosts in those folders become unfiled (root).",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
await api.deleteFolder(id);
|
||||||
|
if (currentFolderId.value === id) {
|
||||||
|
currentFolderId.value = null;
|
||||||
|
}
|
||||||
|
await refreshData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteHostRow(id: number) {
|
||||||
|
if (!confirm("Remove this host entry?")) return;
|
||||||
|
await api.deleteHost(id);
|
||||||
|
allHosts.value = allHosts.value.filter((h) => h.id !== id);
|
||||||
|
tabs.value = tabs.value.filter((t) => t.hostId !== id);
|
||||||
|
if (!tabs.value.some((t) => t.id === activeTabId.value)) {
|
||||||
|
activeTabId.value = tabs.value.length
|
||||||
|
? tabs.value[tabs.value.length - 1].id
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
await refreshBrowse();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteIdentityRow(id: number) {
|
||||||
|
if (!confirm("Remove this identity? Hosts using it may break.")) return;
|
||||||
|
await api.deleteIdentity(id);
|
||||||
|
await refreshData();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="checking" class="flex min-h-screen items-center justify-center text-slate-500">
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
<LoginForm v-else-if="!loggedIn" @logged-in="onLoggedIn" />
|
||||||
|
<div v-else class="flex h-screen min-h-0 flex-col bg-surface font-sans">
|
||||||
|
<header
|
||||||
|
class="flex shrink-0 items-center justify-between border-b border-slate-800 bg-surface-raised px-3 py-2 md:px-4"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-slate-300 hover:bg-slate-800 hover:text-white"
|
||||||
|
aria-controls="hosts-sidebar"
|
||||||
|
:aria-expanded="sidebarOpen"
|
||||||
|
aria-label="Toggle hosts menu"
|
||||||
|
@click="toggleSidebar"
|
||||||
|
>
|
||||||
|
<span class="sr-only">Menu</span>
|
||||||
|
<svg
|
||||||
|
class="h-5 w-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M4 6h16M4 12h16M4 18h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span class="truncate text-sm font-semibold text-white">JDB-NET SSH</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg px-3 py-1.5 text-xs text-slate-400 hover:bg-slate-800 hover:text-white"
|
||||||
|
@click="openAuditLog"
|
||||||
|
>
|
||||||
|
Connection audit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg px-3 py-1.5 text-xs text-slate-400 hover:bg-slate-800 hover:text-white"
|
||||||
|
@click="logout"
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="relative flex min-h-0 flex-1">
|
||||||
|
<div
|
||||||
|
v-show="sidebarOpen"
|
||||||
|
class="fixed inset-0 top-14 z-30 bg-black/50 md:hidden"
|
||||||
|
aria-hidden="true"
|
||||||
|
@click="sidebarOpen = false"
|
||||||
|
/>
|
||||||
|
<aside
|
||||||
|
id="hosts-sidebar"
|
||||||
|
class="flex w-72 shrink-0 flex-col border-r border-slate-800 bg-surface-raised transition-all duration-200 ease-out max-md:fixed max-md:bottom-0 max-md:left-0 max-md:top-14 max-md:z-40 max-md:max-h-[calc(100dvh-3.5rem)] max-md:shadow-2xl md:relative"
|
||||||
|
:class="
|
||||||
|
sidebarOpen
|
||||||
|
? 'max-md:translate-x-0 md:translate-x-0 md:w-72 md:opacity-100'
|
||||||
|
: 'max-md:-translate-x-full md:-translate-x-full md:w-0 md:opacity-0 md:border-r-0 md:overflow-hidden'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div class="border-b border-slate-800 p-3">
|
||||||
|
<div class="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||||
|
Hosts
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="search"
|
||||||
|
placeholder="Search in this folder…"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
v-if="searchActive && searchQuery.trim()"
|
||||||
|
class="mt-1 text-[10px] text-slate-500"
|
||||||
|
>
|
||||||
|
Matches in
|
||||||
|
{{
|
||||||
|
currentFolderId == null
|
||||||
|
? "all folders"
|
||||||
|
: "this folder and below"
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<nav class="mt-2 flex flex-wrap items-center gap-1 text-[11px] text-slate-400">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded px-1.5 py-0.5 hover:bg-slate-800 hover:text-white"
|
||||||
|
@click="goToFolder(null)"
|
||||||
|
>
|
||||||
|
Home
|
||||||
|
</button>
|
||||||
|
<template v-for="c in breadcrumb" :key="c.id">
|
||||||
|
<span class="text-slate-600">/</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded px-1.5 py-0.5 hover:bg-slate-800 hover:text-white"
|
||||||
|
@click="goToFolder(c.id)"
|
||||||
|
>
|
||||||
|
{{ c.label }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</nav>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded bg-accent/20 px-2 py-1 text-xs text-accent hover:bg-accent/30"
|
||||||
|
@click="showHostForm = true"
|
||||||
|
>
|
||||||
|
Add host
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded bg-slate-800 px-2 py-1 text-xs hover:bg-slate-700"
|
||||||
|
@click="showFolderForm = true"
|
||||||
|
>
|
||||||
|
New folder
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded bg-slate-800 px-2 py-1 text-xs hover:bg-slate-700"
|
||||||
|
@click="showIdentityForm = true"
|
||||||
|
>
|
||||||
|
Identities
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="min-h-0 flex-1 overflow-auto p-2">
|
||||||
|
<p v-if="loadErr" class="mb-2 text-xs text-red-400">{{ loadErr }}</p>
|
||||||
|
<ul v-if="!searchActive" class="mb-3 space-y-1">
|
||||||
|
<li
|
||||||
|
v-for="f in browseFolders"
|
||||||
|
:key="'f' + f.id"
|
||||||
|
class="flex items-center justify-between gap-1 rounded-lg border border-slate-800 bg-surface-overlay px-2 py-1.5"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="min-w-0 flex-1 truncate text-left text-sm text-accent hover:underline"
|
||||||
|
@click="goToFolder(f.id)"
|
||||||
|
>
|
||||||
|
📁 {{ f.label }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 text-[10px] text-red-400/80 hover:underline"
|
||||||
|
@click="deleteFolderRow(f.id)"
|
||||||
|
>
|
||||||
|
Del
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
<li
|
||||||
|
v-for="h in browseHosts"
|
||||||
|
:key="'h' + h.id"
|
||||||
|
class="rounded-lg border border-slate-800 bg-surface-overlay p-2"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full text-left text-sm font-medium text-white hover:text-accent"
|
||||||
|
@click="openTab(h)"
|
||||||
|
>
|
||||||
|
{{ h.label }}
|
||||||
|
</button>
|
||||||
|
<div class="mt-0.5 truncate font-mono text-[10px] text-slate-500">
|
||||||
|
{{ h.hostname }}:{{ h.port }} · {{ h.identity_label }}
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="h.jump_host_id"
|
||||||
|
class="mt-0.5 truncate text-[10px] text-slate-500"
|
||||||
|
>
|
||||||
|
via {{ h.jump_host_label || `#${h.jump_host_id}` }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="searchActive"
|
||||||
|
class="mt-0.5 truncate text-[10px] text-slate-600"
|
||||||
|
>
|
||||||
|
{{ folderOptionLabel(h.folder_id) }}
|
||||||
|
</p>
|
||||||
|
<div class="mt-1 flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-[10px] text-slate-400 hover:text-white hover:underline"
|
||||||
|
@click="openEditHost(h)"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-[10px] text-red-400/80 hover:underline"
|
||||||
|
@click="deleteHostRow(h.id)"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p
|
||||||
|
v-if="!browseFolders.length && !browseHosts.length && !loadErr"
|
||||||
|
class="py-4 text-center text-xs text-slate-500"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
searchActive
|
||||||
|
? "No hosts match."
|
||||||
|
: "Empty folder — add a host or subfolder."
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-slate-800 p-2">
|
||||||
|
<div class="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||||
|
Saved identities
|
||||||
|
</div>
|
||||||
|
<ul class="mt-1 max-h-32 overflow-auto text-xs text-slate-400">
|
||||||
|
<li
|
||||||
|
v-for="i in identities"
|
||||||
|
:key="i.id"
|
||||||
|
class="flex items-center justify-between gap-1 py-0.5"
|
||||||
|
>
|
||||||
|
<span class="truncate">{{ i.label }} ({{ i.auth_type }})</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 text-red-400/70 hover:underline"
|
||||||
|
@click="deleteIdentityRow(i.id)"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<main class="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||||
|
<div
|
||||||
|
v-if="!tabs.length"
|
||||||
|
class="flex flex-1 items-center justify-center text-sm text-slate-500"
|
||||||
|
>
|
||||||
|
Select a host to open a tab, or add one from the sidebar.
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<div
|
||||||
|
class="flex shrink-0 gap-1 overflow-x-auto border-b border-slate-800 bg-surface-raised px-2 pt-2"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="t in tabs"
|
||||||
|
:key="t.id"
|
||||||
|
type="button"
|
||||||
|
class="flex items-center gap-2 rounded-t-lg border border-b-0 px-3 py-2 text-sm"
|
||||||
|
:class="
|
||||||
|
t.id === activeTabId
|
||||||
|
? 'border-slate-700 bg-surface text-white'
|
||||||
|
: 'border-transparent bg-transparent text-slate-400 hover:text-white'
|
||||||
|
"
|
||||||
|
@click="activeTabId = t.id"
|
||||||
|
>
|
||||||
|
{{ t.label }}
|
||||||
|
<span
|
||||||
|
class="rounded px-1 text-slate-500 hover:bg-slate-800 hover:text-white"
|
||||||
|
@click.stop="closeTab(t.id)"
|
||||||
|
>×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="min-h-0 flex-1 p-2 md:p-3">
|
||||||
|
<div
|
||||||
|
v-for="t in tabs"
|
||||||
|
v-show="t.id === activeTabId"
|
||||||
|
:key="t.id"
|
||||||
|
class="h-full min-h-0"
|
||||||
|
>
|
||||||
|
<TabContent
|
||||||
|
:host-id="t.hostId"
|
||||||
|
:visible="t.id === activeTabId"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="showAuditLog"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||||
|
@click.self="showAuditLog = false"
|
||||||
|
>
|
||||||
|
<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">Connection audit</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="showAuditLog = false"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">
|
||||||
|
Recent SSH sessions and how long they lasted.
|
||||||
|
</p>
|
||||||
|
<p v-if="auditErr" class="mt-3 text-xs text-red-400">{{ auditErr }}</p>
|
||||||
|
<p v-else-if="auditLoading" class="mt-3 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">Host</th>
|
||||||
|
<th class="px-2 py-2 font-medium">Address</th>
|
||||||
|
<th class="px-2 py-2 font-medium">Started</th>
|
||||||
|
<th class="px-2 py-2 font-medium">Ended</th>
|
||||||
|
<th class="px-2 py-2 font-medium">Duration</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="r in auditRows"
|
||||||
|
:key="r.id"
|
||||||
|
class="border-b border-slate-900/80 text-slate-300"
|
||||||
|
>
|
||||||
|
<td class="px-2 py-2">
|
||||||
|
<div class="truncate">{{ r.host_label }}</div>
|
||||||
|
<div
|
||||||
|
v-if="r.jump_host_id"
|
||||||
|
class="truncate text-[10px] text-slate-500"
|
||||||
|
>
|
||||||
|
via #{{ r.jump_host_id }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-2 font-mono text-[11px]">
|
||||||
|
{{ r.hostname }}:{{ r.port }}
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-2">{{ fmtDate(r.started_at) }}</td>
|
||||||
|
<td class="px-2 py-2">{{ fmtDate(r.ended_at) }}</td>
|
||||||
|
<td class="px-2 py-2">{{ fmtDuration(r.duration_seconds) }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!auditRows.length">
|
||||||
|
<td class="px-2 py-4 text-center text-slate-500" colspan="5">
|
||||||
|
No connection sessions yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="showIdentityForm"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||||
|
@click.self="showIdentityForm = false"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
class="max-h-[90vh] w-full max-w-lg overflow-auto rounded-xl border border-slate-800 bg-surface-raised p-6 shadow-xl"
|
||||||
|
@submit.prevent="submitIdentity"
|
||||||
|
>
|
||||||
|
<h2 class="text-lg font-semibold text-white">New identity</h2>
|
||||||
|
<label class="mt-4 block text-xs uppercase text-slate-500">Label</label>
|
||||||
|
<input
|
||||||
|
v-model="identityForm.label"
|
||||||
|
required
|
||||||
|
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">Auth</label>
|
||||||
|
<select
|
||||||
|
v-model="identityForm.auth_type"
|
||||||
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<option value="password">Password</option>
|
||||||
|
<option value="publickey">SSH private key</option>
|
||||||
|
</select>
|
||||||
|
<label class="mt-3 block text-xs uppercase text-slate-500">SSH username</label>
|
||||||
|
<input
|
||||||
|
v-model="identityForm.ssh_username"
|
||||||
|
required
|
||||||
|
autocomplete="off"
|
||||||
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
<template v-if="identityForm.auth_type === 'password'">
|
||||||
|
<label class="mt-3 block text-xs uppercase text-slate-500">Password</label>
|
||||||
|
<input
|
||||||
|
v-model="identityForm.password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<label class="mt-3 block text-xs uppercase text-slate-500">Private key (PEM)</label>
|
||||||
|
<textarea
|
||||||
|
v-model="identityForm.private_key"
|
||||||
|
required
|
||||||
|
rows="6"
|
||||||
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<label class="mt-3 block text-xs uppercase text-slate-500">Key passphrase (optional)</label>
|
||||||
|
<input
|
||||||
|
v-model="identityForm.key_passphrase"
|
||||||
|
type="password"
|
||||||
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<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="showIdentityForm = 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
|
||||||
|
v-if="showHostForm"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||||
|
@click.self="showHostForm = false"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
class="w-full max-w-md rounded-xl border border-slate-800 bg-surface-raised p-6 shadow-xl"
|
||||||
|
@submit.prevent="submitHost"
|
||||||
|
>
|
||||||
|
<h2 class="text-lg font-semibold text-white">New host</h2>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">
|
||||||
|
Folder: {{ folderOptionLabel(currentFolderId) }}
|
||||||
|
</p>
|
||||||
|
<label class="mt-4 block text-xs uppercase text-slate-500">Label</label>
|
||||||
|
<input
|
||||||
|
v-model="hostForm.label"
|
||||||
|
required
|
||||||
|
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">Hostname</label>
|
||||||
|
<input
|
||||||
|
v-model="hostForm.hostname"
|
||||||
|
required
|
||||||
|
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">Port</label>
|
||||||
|
<input
|
||||||
|
v-model.number="hostForm.port"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
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">Identity</label>
|
||||||
|
<select
|
||||||
|
v-model.number="hostForm.identity_id"
|
||||||
|
required
|
||||||
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<option v-for="i in identities" :key="i.id" :value="i.id">
|
||||||
|
{{ i.label }} ({{ i.auth_type }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<label class="mt-3 block text-xs uppercase text-slate-500">Jump host (optional)</label>
|
||||||
|
<select
|
||||||
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
|
:value="hostForm.jump_host_id === null ? '' : String(hostForm.jump_host_id)"
|
||||||
|
@change="
|
||||||
|
hostForm.jump_host_id =
|
||||||
|
($event.target as HTMLSelectElement).value === ''
|
||||||
|
? null
|
||||||
|
: Number(($event.target as HTMLSelectElement).value)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<option value="">None (direct)</option>
|
||||||
|
<option v-for="h in allHosts" :key="h.id" :value="String(h.id)">
|
||||||
|
{{ h.label }} ({{ h.hostname }}:{{ h.port }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<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="showHostForm = false"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-slate-950"
|
||||||
|
:disabled="!identities.length"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="showFolderForm"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||||
|
@click.self="showFolderForm = false"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
class="w-full max-w-sm rounded-xl border border-slate-800 bg-surface-raised p-6 shadow-xl"
|
||||||
|
@submit.prevent="submitFolder"
|
||||||
|
>
|
||||||
|
<h2 class="text-lg font-semibold text-white">New folder</h2>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">
|
||||||
|
Inside: {{ folderOptionLabel(currentFolderId) }}
|
||||||
|
</p>
|
||||||
|
<label class="mt-4 block text-xs uppercase text-slate-500">Name</label>
|
||||||
|
<input
|
||||||
|
v-model="newFolderLabel"
|
||||||
|
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="showFolderForm = false"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-slate-950"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="showEditHost"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||||
|
@click.self="showEditHost = false"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
class="max-h-[90vh] w-full max-w-md overflow-auto rounded-xl border border-slate-800 bg-surface-raised p-6 shadow-xl"
|
||||||
|
@submit.prevent="submitEditHost"
|
||||||
|
>
|
||||||
|
<h2 class="text-lg font-semibold text-white">Edit host</h2>
|
||||||
|
<label class="mt-4 block text-xs uppercase text-slate-500">Label</label>
|
||||||
|
<input
|
||||||
|
v-model="editHostForm.label"
|
||||||
|
required
|
||||||
|
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">Hostname</label>
|
||||||
|
<input
|
||||||
|
v-model="editHostForm.hostname"
|
||||||
|
required
|
||||||
|
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">Port</label>
|
||||||
|
<input
|
||||||
|
v-model.number="editHostForm.port"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="65535"
|
||||||
|
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">Folder</label>
|
||||||
|
<select
|
||||||
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
|
:value="editHostForm.folder_id === null ? '' : String(editHostForm.folder_id)"
|
||||||
|
@change="
|
||||||
|
editHostForm.folder_id =
|
||||||
|
($event.target as HTMLSelectElement).value === ''
|
||||||
|
? null
|
||||||
|
: Number(($event.target as HTMLSelectElement).value)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<option value="">(root)</option>
|
||||||
|
<option
|
||||||
|
v-for="f in allFolders"
|
||||||
|
:key="f.id"
|
||||||
|
:value="String(f.id)"
|
||||||
|
>
|
||||||
|
{{ folderOptionLabel(f.id) }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<label class="mt-3 block text-xs uppercase text-slate-500">Identity</label>
|
||||||
|
<select
|
||||||
|
v-model.number="editHostForm.identity_id"
|
||||||
|
required
|
||||||
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
<option v-for="i in identities" :key="i.id" :value="i.id">
|
||||||
|
{{ i.label }} ({{ i.auth_type }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<label class="mt-3 block text-xs uppercase text-slate-500">Jump host (optional)</label>
|
||||||
|
<select
|
||||||
|
class="mt-1 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1.5 text-sm"
|
||||||
|
:value="editHostForm.jump_host_id === null ? '' : String(editHostForm.jump_host_id)"
|
||||||
|
@change="
|
||||||
|
editHostForm.jump_host_id =
|
||||||
|
($event.target as HTMLSelectElement).value === ''
|
||||||
|
? null
|
||||||
|
: Number(($event.target as HTMLSelectElement).value)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<option value="">None (direct)</option>
|
||||||
|
<option
|
||||||
|
v-for="h in allHosts.filter((x) => x.id !== editHostForm.id)"
|
||||||
|
:key="h.id"
|
||||||
|
:value="String(h.id)"
|
||||||
|
>
|
||||||
|
{{ h.label }} ({{ h.hostname }}:{{ h.port }})
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<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="showEditHost = 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>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
const jsonHeaders = { "Content-Type": "application/json" };
|
||||||
|
|
||||||
|
async function handle<T>(res: Response): Promise<T> {
|
||||||
|
if (res.status === 401) {
|
||||||
|
throw new Error("unauthorized");
|
||||||
|
}
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error((data as { error?: string }).error || res.statusText);
|
||||||
|
}
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function browseParams(folderId: number | null, q: string): string {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
if (folderId != null) p.set("folder_id", String(folderId));
|
||||||
|
else p.set("folder_id", "root");
|
||||||
|
const t = q.trim();
|
||||||
|
if (t) p.set("q", t);
|
||||||
|
return p.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
async me(): Promise<{ logged_in: boolean }> {
|
||||||
|
const res = await fetch("/api/me", { credentials: "include" });
|
||||||
|
return handle(res);
|
||||||
|
},
|
||||||
|
|
||||||
|
async login(username: string, password: string): Promise<void> {
|
||||||
|
const res = await fetch("/api/login", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: jsonHeaders,
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
await handle(res);
|
||||||
|
},
|
||||||
|
|
||||||
|
async logout(): Promise<void> {
|
||||||
|
const res = await fetch("/api/logout", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
await handle(res);
|
||||||
|
},
|
||||||
|
|
||||||
|
async browse(
|
||||||
|
folderId: number | null,
|
||||||
|
q: string,
|
||||||
|
): Promise<{
|
||||||
|
breadcrumb: { id: number; label: string }[];
|
||||||
|
folders: FolderRow[];
|
||||||
|
hosts: HostRow[];
|
||||||
|
search_active: boolean;
|
||||||
|
}> {
|
||||||
|
const res = await fetch(`/api/browse?${browseParams(folderId, q)}`, {
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
return handle(res);
|
||||||
|
},
|
||||||
|
|
||||||
|
async listHosts(): Promise<HostRow[]> {
|
||||||
|
const res = await fetch("/api/hosts", { credentials: "include" });
|
||||||
|
const d = await handle<{ items: HostRow[] }>(res);
|
||||||
|
return d.items;
|
||||||
|
},
|
||||||
|
|
||||||
|
async listFoldersFlat(): Promise<FolderRow[]> {
|
||||||
|
const res = await fetch("/api/folders", { credentials: "include" });
|
||||||
|
const d = await handle<{ items: FolderRow[] }>(res);
|
||||||
|
return d.items;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createFolder(body: {
|
||||||
|
label: string;
|
||||||
|
parent_id?: number | null;
|
||||||
|
}): Promise<{ id: number }> {
|
||||||
|
const res = await fetch("/api/folders", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: jsonHeaders,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return handle(res);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteFolder(id: number): Promise<void> {
|
||||||
|
const res = await fetch(`/api/folders/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
await handle(res);
|
||||||
|
},
|
||||||
|
|
||||||
|
async listIdentities(): Promise<IdentityRow[]> {
|
||||||
|
const res = await fetch("/api/identities", { credentials: "include" });
|
||||||
|
const d = await handle<{ items: IdentityRow[] }>(res);
|
||||||
|
return d.items;
|
||||||
|
},
|
||||||
|
|
||||||
|
async createHost(body: {
|
||||||
|
label: string;
|
||||||
|
hostname: string;
|
||||||
|
port?: number;
|
||||||
|
identity_id: number;
|
||||||
|
folder_id?: number | null;
|
||||||
|
jump_host_id?: number | null;
|
||||||
|
}): Promise<{ id: number }> {
|
||||||
|
const res = await fetch("/api/hosts", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: jsonHeaders,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return handle(res);
|
||||||
|
},
|
||||||
|
|
||||||
|
async patchHost(
|
||||||
|
id: number,
|
||||||
|
body: Partial<{
|
||||||
|
label: string;
|
||||||
|
hostname: string;
|
||||||
|
port: number;
|
||||||
|
identity_id: number;
|
||||||
|
folder_id: number | null;
|
||||||
|
jump_host_id: number | null;
|
||||||
|
}>,
|
||||||
|
): Promise<void> {
|
||||||
|
const res = await fetch(`/api/hosts/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
credentials: "include",
|
||||||
|
headers: jsonHeaders,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
await handle(res);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteHost(id: number): Promise<void> {
|
||||||
|
const res = await fetch(`/api/hosts/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
await handle(res);
|
||||||
|
},
|
||||||
|
|
||||||
|
async createIdentity(body: Record<string, unknown>): Promise<{ id: number }> {
|
||||||
|
const res = await fetch("/api/identities", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: jsonHeaders,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
return handle(res);
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteIdentity(id: number): Promise<void> {
|
||||||
|
const res = await fetch(`/api/identities/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
await handle(res);
|
||||||
|
},
|
||||||
|
|
||||||
|
async listConnectionAudit(limit = 200): Promise<ConnectionAuditRow[]> {
|
||||||
|
const q = new URLSearchParams({ limit: String(limit) });
|
||||||
|
const res = await fetch(`/api/audit/connections?${q.toString()}`, {
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
const d = await handle<{ items: ConnectionAuditRow[] }>(res);
|
||||||
|
return d.items;
|
||||||
|
},
|
||||||
|
|
||||||
|
async sftpList(
|
||||||
|
connId: string,
|
||||||
|
path: string,
|
||||||
|
): Promise<{ path: string; entries: SftpEntry[] }> {
|
||||||
|
const res = await fetch(`/api/sftp/${connId}/list`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: jsonHeaders,
|
||||||
|
body: JSON.stringify({ path }),
|
||||||
|
});
|
||||||
|
return handle(res);
|
||||||
|
},
|
||||||
|
|
||||||
|
async sftpMkdir(connId: string, path: string): Promise<void> {
|
||||||
|
const res = await fetch(`/api/sftp/${connId}/mkdir`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: jsonHeaders,
|
||||||
|
body: JSON.stringify({ path }),
|
||||||
|
});
|
||||||
|
await handle(res);
|
||||||
|
},
|
||||||
|
|
||||||
|
async sftpRemove(connId: string, path: string): Promise<void> {
|
||||||
|
const res = await fetch(`/api/sftp/${connId}/remove`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: jsonHeaders,
|
||||||
|
body: JSON.stringify({ path }),
|
||||||
|
});
|
||||||
|
await handle(res);
|
||||||
|
},
|
||||||
|
|
||||||
|
async sftpRename(
|
||||||
|
connId: string,
|
||||||
|
oldPath: string,
|
||||||
|
newPath: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const res = await fetch(`/api/sftp/${connId}/rename`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: jsonHeaders,
|
||||||
|
body: JSON.stringify({ old_path: oldPath, new_path: newPath }),
|
||||||
|
});
|
||||||
|
await handle(res);
|
||||||
|
},
|
||||||
|
|
||||||
|
async sftpUpload(connId: string, path: string, file: File): Promise<void> {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set("path", path);
|
||||||
|
fd.set("file", file);
|
||||||
|
const res = await fetch(`/api/sftp/${connId}/upload`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
body: fd,
|
||||||
|
});
|
||||||
|
await handle(res);
|
||||||
|
},
|
||||||
|
|
||||||
|
sftpDownloadUrl(connId: string, path: string): string {
|
||||||
|
const q = new URLSearchParams({ path });
|
||||||
|
return `/api/sftp/${connId}/download?${q}`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface FolderRow {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
parent_id: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HostRow {
|
||||||
|
id: number;
|
||||||
|
folder_id: number | null;
|
||||||
|
label: string;
|
||||||
|
hostname: string;
|
||||||
|
port: number;
|
||||||
|
identity_id: number;
|
||||||
|
jump_host_id: number | null;
|
||||||
|
jump_host_label?: string | null;
|
||||||
|
identity_label: string;
|
||||||
|
identity_auth_type: string;
|
||||||
|
folder_label?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IdentityRow {
|
||||||
|
id: number;
|
||||||
|
label: string;
|
||||||
|
auth_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SftpEntry {
|
||||||
|
filename: string;
|
||||||
|
st_mode: number;
|
||||||
|
st_size: number;
|
||||||
|
st_mtime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectionAuditRow {
|
||||||
|
id: number;
|
||||||
|
host_id: number | null;
|
||||||
|
host_label: string;
|
||||||
|
hostname: string;
|
||||||
|
port: number;
|
||||||
|
jump_host_id: number | null;
|
||||||
|
started_at: string;
|
||||||
|
ended_at: string | null;
|
||||||
|
duration_seconds: number | null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { api } from "@/api";
|
||||||
|
|
||||||
|
const emit = defineEmits<{ loggedIn: [] }>();
|
||||||
|
|
||||||
|
const username = ref("");
|
||||||
|
const password = ref("");
|
||||||
|
const err = ref("");
|
||||||
|
const busy = ref(false);
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
err.value = "";
|
||||||
|
busy.value = true;
|
||||||
|
try {
|
||||||
|
await api.login(username.value.trim(), password.value);
|
||||||
|
emit("loggedIn");
|
||||||
|
} catch (e) {
|
||||||
|
err.value = e instanceof Error ? e.message : "Login failed";
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex min-h-screen items-center justify-center bg-surface p-6"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
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">
|
||||||
|
JDB-NET SSH
|
||||||
|
</h1>
|
||||||
|
<p class="mt-1 text-sm text-slate-400">
|
||||||
|
Sign in to manage connections and open terminals.
|
||||||
|
</p>
|
||||||
|
<form class="mt-8 space-y-4" @submit.prevent="submit">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Username</label>
|
||||||
|
<input
|
||||||
|
v-model="username"
|
||||||
|
type="text"
|
||||||
|
autocomplete="username"
|
||||||
|
class="w-full rounded-lg border border-slate-700 bg-surface-overlay px-3 py-2 text-sm text-white outline-none ring-accent focus:border-accent focus:ring-1"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs font-medium uppercase tracking-wide text-slate-500">Password</label>
|
||||||
|
<input
|
||||||
|
v-model="password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
class="w-full rounded-lg border border-slate-700 bg-surface-overlay px-3 py-2 text-sm text-white outline-none ring-accent focus:border-accent focus:ring-1"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p v-if="err" class="text-sm text-red-400">{{ err }}</p>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="busy"
|
||||||
|
class="w-full rounded-lg bg-accent px-4 py-2.5 text-sm font-medium text-slate-950 transition hover:bg-sky-400 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ busy ? "Signing in…" : "Sign in" }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from "vue";
|
||||||
|
import { api, type SftpEntry } from "@/api";
|
||||||
|
|
||||||
|
const props = defineProps<{ connId: string }>();
|
||||||
|
|
||||||
|
const path = ref("/");
|
||||||
|
const entries = ref<SftpEntry[]>([]);
|
||||||
|
const err = ref("");
|
||||||
|
const busy = ref(false);
|
||||||
|
const renameTarget = ref<SftpEntry | null>(null);
|
||||||
|
const newName = ref("");
|
||||||
|
|
||||||
|
function isDir(m: number): boolean {
|
||||||
|
return (m & 0o170000) === 0o040000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinRemote(base: string, name: string): string {
|
||||||
|
if (base === "/") return `/${name}`.replace("//", "/");
|
||||||
|
return `${base.replace(/\/$/, "")}/${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
err.value = "";
|
||||||
|
busy.value = true;
|
||||||
|
try {
|
||||||
|
const r = await api.sftpList(props.connId, path.value);
|
||||||
|
path.value = r.path;
|
||||||
|
entries.value = r.entries;
|
||||||
|
} catch (e) {
|
||||||
|
err.value = e instanceof Error ? e.message : "List failed";
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.connId,
|
||||||
|
() => {
|
||||||
|
path.value = "/";
|
||||||
|
load();
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
function enter(e: SftpEntry) {
|
||||||
|
const p = joinRemote(path.value, e.filename);
|
||||||
|
if (isDir(e.st_mode)) {
|
||||||
|
path.value = p;
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parent() {
|
||||||
|
if (path.value === "/") return;
|
||||||
|
const parts = path.value.replace(/\/$/, "").split("/");
|
||||||
|
parts.pop();
|
||||||
|
path.value = parts.length ? parts.join("/") || "/" : "/";
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onUpload(ev: Event) {
|
||||||
|
const input = ev.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
input.value = "";
|
||||||
|
if (!file) return;
|
||||||
|
err.value = "";
|
||||||
|
try {
|
||||||
|
await api.sftpUpload(props.connId, path.value, file);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
err.value = e instanceof Error ? e.message : "Upload failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFile(e: SftpEntry) {
|
||||||
|
const p = joinRemote(path.value, e.filename);
|
||||||
|
err.value = "";
|
||||||
|
try {
|
||||||
|
const res = await fetch(api.sftpDownloadUrl(props.connId, p), {
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = e.filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (e) {
|
||||||
|
err.value = e instanceof Error ? e.message : "Download failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeEntry(e: SftpEntry) {
|
||||||
|
if (!confirm(`Delete ${e.filename}?`)) return;
|
||||||
|
const p = joinRemote(path.value, e.filename);
|
||||||
|
err.value = "";
|
||||||
|
try {
|
||||||
|
await api.sftpRemove(props.connId, p);
|
||||||
|
await load();
|
||||||
|
} catch (err2) {
|
||||||
|
err.value = err2 instanceof Error ? err2.message : "Remove failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRename(e: SftpEntry) {
|
||||||
|
renameTarget.value = e;
|
||||||
|
newName.value = e.filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmRename() {
|
||||||
|
if (!renameTarget.value) return;
|
||||||
|
const oldP = joinRemote(path.value, renameTarget.value.filename);
|
||||||
|
const newP = joinRemote(path.value, newName.value.trim());
|
||||||
|
renameTarget.value = null;
|
||||||
|
err.value = "";
|
||||||
|
try {
|
||||||
|
await api.sftpRename(props.connId, oldP, newP);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
err.value = e instanceof Error ? e.message : "Rename failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mkdir() {
|
||||||
|
const name = prompt("Folder name");
|
||||||
|
if (!name?.trim()) return;
|
||||||
|
const p = joinRemote(path.value, name.trim());
|
||||||
|
err.value = "";
|
||||||
|
try {
|
||||||
|
await api.sftpMkdir(props.connId, p);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
err.value = e instanceof Error ? e.message : "mkdir failed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtSize(n: number): string {
|
||||||
|
if (n < 1024) return `${n} B`;
|
||||||
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex min-h-0 flex-col bg-surface-raised font-sans text-sm">
|
||||||
|
<div class="border-b border-slate-800 px-3 py-2">
|
||||||
|
<div class="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||||
|
SFTP
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 truncate font-mono text-xs text-slate-300" :title="path">
|
||||||
|
{{ path }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded bg-slate-800 px-2 py-1 text-xs hover:bg-slate-700"
|
||||||
|
@click="parent"
|
||||||
|
>
|
||||||
|
Up
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded bg-slate-800 px-2 py-1 text-xs hover:bg-slate-700"
|
||||||
|
@click="load"
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded bg-slate-800 px-2 py-1 text-xs hover:bg-slate-700"
|
||||||
|
@click="mkdir"
|
||||||
|
>
|
||||||
|
New folder
|
||||||
|
</button>
|
||||||
|
<label
|
||||||
|
class="cursor-pointer rounded bg-accent/20 px-2 py-1 text-xs text-accent hover:bg-accent/30"
|
||||||
|
>
|
||||||
|
Upload
|
||||||
|
<input type="file" class="hidden" @change="onUpload" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="min-h-0 flex-1 overflow-auto p-2">
|
||||||
|
<p v-if="err" class="mb-2 text-xs text-red-400">{{ err }}</p>
|
||||||
|
<p v-if="busy" class="text-xs text-slate-500">Loading…</p>
|
||||||
|
<ul v-else class="space-y-0.5">
|
||||||
|
<li
|
||||||
|
v-for="e in entries"
|
||||||
|
:key="e.filename"
|
||||||
|
class="group flex items-center gap-2 rounded px-2 py-1 hover:bg-surface-overlay"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="min-w-0 flex-1 truncate text-left font-mono text-xs"
|
||||||
|
:class="isDir(e.st_mode) ? 'text-accent' : 'text-slate-200'"
|
||||||
|
@click="enter(e)"
|
||||||
|
>
|
||||||
|
{{ isDir(e.st_mode) ? "📁" : "📄" }} {{ e.filename }}
|
||||||
|
</button>
|
||||||
|
<span class="shrink-0 text-[10px] text-slate-500">{{
|
||||||
|
isDir(e.st_mode) ? "" : fmtSize(e.st_size)
|
||||||
|
}}</span>
|
||||||
|
<button
|
||||||
|
v-if="!isDir(e.st_mode)"
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 text-[10px] text-slate-500 opacity-0 group-hover:opacity-100 hover:text-accent"
|
||||||
|
@click="downloadFile(e)"
|
||||||
|
>
|
||||||
|
Get
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 text-[10px] text-slate-500 opacity-0 group-hover:opacity-100 hover:text-accent"
|
||||||
|
@click="startRename(e)"
|
||||||
|
>
|
||||||
|
Ren
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 text-[10px] text-red-400/80 opacity-0 group-hover:opacity-100"
|
||||||
|
@click="removeEntry(e)"
|
||||||
|
>
|
||||||
|
Del
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="renameTarget"
|
||||||
|
class="border-t border-slate-800 p-2"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="newName"
|
||||||
|
class="mb-2 w-full rounded border border-slate-700 bg-surface-overlay px-2 py-1 font-mono text-xs"
|
||||||
|
@keyup.enter="confirmRename"
|
||||||
|
/>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded bg-accent px-2 py-1 text-xs text-slate-950"
|
||||||
|
@click="confirmRename"
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded bg-slate-800 px-2 py-1 text-xs"
|
||||||
|
@click="renameTarget = null"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
onMounted,
|
||||||
|
onUnmounted,
|
||||||
|
ref,
|
||||||
|
watch,
|
||||||
|
nextTick,
|
||||||
|
} from "vue";
|
||||||
|
import { Terminal } from "@xterm/xterm";
|
||||||
|
import { FitAddon } from "@xterm/addon-fit";
|
||||||
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
import SftpPanel from "./SftpPanel.vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
hostId: number;
|
||||||
|
visible: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const termEl = ref<HTMLElement | null>(null);
|
||||||
|
const status = ref("Connecting…");
|
||||||
|
const connId = ref<string | null>(null);
|
||||||
|
|
||||||
|
let ws: WebSocket | null = null;
|
||||||
|
let term: Terminal | null = null;
|
||||||
|
let fit: FitAddon | null = null;
|
||||||
|
let ro: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
function wsUrl(hostId: number): string {
|
||||||
|
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
return `${proto}//${location.host}/ws/terminal?host_id=${hostId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendResize() {
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN || !term) return;
|
||||||
|
const dims = { cols: term.cols, rows: term.rows };
|
||||||
|
ws.send(JSON.stringify({ type: "resize", ...dims }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitAndResize() {
|
||||||
|
if (!fit || !term || !props.visible) return;
|
||||||
|
try {
|
||||||
|
fit.fit();
|
||||||
|
sendResize();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await nextTick();
|
||||||
|
if (!termEl.value) return;
|
||||||
|
|
||||||
|
term = new Terminal({
|
||||||
|
cursorBlink: true,
|
||||||
|
fontFamily: "IBM Plex Mono, monospace",
|
||||||
|
fontSize: 14,
|
||||||
|
theme: {
|
||||||
|
background: "#0a0e12",
|
||||||
|
foreground: "#e2e8f0",
|
||||||
|
cursor: "#3d9aed",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
fit = new FitAddon();
|
||||||
|
term.loadAddon(fit);
|
||||||
|
term.open(termEl.value);
|
||||||
|
fit.fit();
|
||||||
|
|
||||||
|
term.onData((data) => {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(new TextEncoder().encode(data));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
term.onResize(({ cols, rows }) => {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ type: "resize", cols, rows }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ro = new ResizeObserver(() => fitAndResize());
|
||||||
|
ro.observe(termEl.value);
|
||||||
|
|
||||||
|
ws = new WebSocket(wsUrl(props.hostId));
|
||||||
|
ws.binaryType = "arraybuffer";
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
status.value = "Handshaking…";
|
||||||
|
sendResize();
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
if (!term) return;
|
||||||
|
if (typeof ev.data === "string") {
|
||||||
|
try {
|
||||||
|
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);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const u8 = new Uint8Array(ev.data as ArrayBuffer);
|
||||||
|
term.write(new TextDecoder().decode(u8));
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
status.value = "WebSocket error";
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (!connId.value) {
|
||||||
|
status.value = "Disconnected";
|
||||||
|
} else {
|
||||||
|
status.value = "Session ended";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
ro?.disconnect();
|
||||||
|
ro = null;
|
||||||
|
ws?.close();
|
||||||
|
ws = null;
|
||||||
|
term?.dispose();
|
||||||
|
term = null;
|
||||||
|
fit = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.visible,
|
||||||
|
async (v) => {
|
||||||
|
if (v) {
|
||||||
|
await nextTick();
|
||||||
|
fitAndResize();
|
||||||
|
term?.focus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex h-full min-h-0 gap-0">
|
||||||
|
<div class="relative min-h-0 min-w-0 flex-1 flex-col">
|
||||||
|
<div
|
||||||
|
v-if="status"
|
||||||
|
class="absolute left-3 top-2 z-10 rounded bg-black/70 px-2 py-1 font-mono text-xs text-amber-200"
|
||||||
|
>
|
||||||
|
{{ status }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref="termEl"
|
||||||
|
class="h-full min-h-[320px] rounded-lg border border-slate-800 bg-[#0a0e12] p-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="connId"
|
||||||
|
class="hidden w-80 shrink-0 flex-col border-l border-slate-800 md:flex"
|
||||||
|
>
|
||||||
|
<SftpPanel :conn-id="connId" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="hidden w-72 shrink-0 items-center justify-center border-l border-slate-800 bg-surface-raised text-xs text-slate-500 md:flex"
|
||||||
|
>
|
||||||
|
SFTP unlocks when the shell session is ready.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { createApp } from "vue";
|
||||||
|
import { createPinia } from "pinia";
|
||||||
|
import App from "./App.vue";
|
||||||
|
import "./style.css";
|
||||||
|
|
||||||
|
const app = createApp(App);
|
||||||
|
app.use(createPinia());
|
||||||
|
app.mount("#app");
|
||||||
|
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
window.addEventListener("load", () => {
|
||||||
|
navigator.serviceWorker.register("/sw.js").catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
html.dark,
|
||||||
|
html {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm {
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xterm-viewport {
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module "*.vue" {
|
||||||
|
import type { DefineComponent } from "vue";
|
||||||
|
const component: DefineComponent<object, object, unknown>;
|
||||||
|
export default component;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
surface: {
|
||||||
|
DEFAULT: "#0f1419",
|
||||||
|
raised: "#151c24",
|
||||||
|
overlay: "#1a232e",
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: "#3d9aed",
|
||||||
|
muted: "#2a6fa3",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["IBM Plex Sans", "system-ui", "sans-serif"],
|
||||||
|
mono: ["IBM Plex Mono", "ui-monospace", "monospace"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"paths": { "@/*": ["./src/*"] }
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { defineConfig, type Plugin } from "vite";
|
||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const staticRoot = path.resolve(__dirname, "../static");
|
||||||
|
|
||||||
|
function servePwaFromStatic(): Plugin {
|
||||||
|
return {
|
||||||
|
name: "serve-pwa-from-static",
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use((req, res, next) => {
|
||||||
|
const url = req.url?.split("?")[0] ?? "";
|
||||||
|
if (url !== "/manifest.webmanifest" && url !== "/sw.js") {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const name = url.slice(1);
|
||||||
|
const filePath = path.join(staticRoot, name);
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = fs.readFileSync(filePath);
|
||||||
|
const type = name.endsWith(".webmanifest")
|
||||||
|
? "application/manifest+json"
|
||||||
|
: "application/javascript";
|
||||||
|
res.setHeader("Content-Type", type);
|
||||||
|
res.end(body);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue(), servePwaFromStatic()],
|
||||||
|
resolve: {
|
||||||
|
alias: { "@": path.resolve(__dirname, "src") },
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: "../static/dist",
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
"/api": "http://127.0.0.1:5000",
|
||||||
|
"/ws": {
|
||||||
|
target: "ws://127.0.0.1:5000",
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
flask
|
||||||
|
mysql-connector-python
|
||||||
|
python-dotenv
|
||||||
|
gunicorn
|
||||||
|
gevent
|
||||||
|
gevent-websocket
|
||||||
|
simple-websocket
|
||||||
|
paramiko
|
||||||
|
cryptography
|
||||||
|
werkzeug
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "SSH",
|
||||||
|
"short_name": "SSH",
|
||||||
|
"description": "Web-based SSH client",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#334155",
|
||||||
|
"theme_color": "#334155",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "https://assets.jdbnet.co.uk/projects/ssh_maskable.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "https://assets.jdbnet.co.uk/projects/ssh_maskable.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
self.addEventListener("install", () => {
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(self.clients.claim());
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user