Compare commits
40 Commits
4e77b8f412
...
v1.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
| c487a4e949 | |||
| b4f31aa0ab | |||
| 0d98e16f29 | |||
| db33658ffa | |||
| a38e3488e4 | |||
| 6539a7f2c4 | |||
| 790cfc581a | |||
| 053c9e8431 | |||
| 59d216f697 | |||
| d81136fe5e | |||
| e43c4a7f2a | |||
| 07c9da8a80 | |||
| 4bdd4c1d8a | |||
| 336334c7f5 | |||
| a502ae2687 | |||
| 782d8446d9 | |||
| 5b79f5fb4b | |||
| a0f84ec78e | |||
| da996705c9 | |||
| 6d341309f1 | |||
| 187a3c7882 | |||
| c7ffdf81c2 | |||
| 0664d8763d | |||
| 853e06456e | |||
| cae073728a | |||
| 0f35d5bd6f | |||
| 1d6cce88a8 | |||
| 18e256baee | |||
| 7682a94981 | |||
| bb724377fe | |||
| 22a3dc7cbe | |||
| 6069f5395a | |||
| d542264567 | |||
| 7f717684eb | |||
| 90103f79c8 | |||
| 20db4742a7 | |||
| ca8b5dea7f | |||
| ca3d27e7f9 | |||
| 035f871b00 | |||
| 5bba2947c4 |
@@ -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
@@ -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
|
||||||
@@ -1,8 +1,6 @@
|
|||||||
name: CI
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
|
||||||
branches: [ main ]
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -17,8 +15,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
run: |
|
run: |
|
||||||
docker build -t cr.jdbnet.co.uk/public/ssh:latest .
|
docker build -t cr.jdbnet.co.uk/public/ssh:dev .
|
||||||
docker push cr.jdbnet.co.uk/public/ssh:latest
|
docker push cr.jdbnet.co.uk/public/ssh:dev
|
||||||
|
|
||||||
sonarqube:
|
sonarqube:
|
||||||
name: SonarQube
|
name: SonarQube
|
||||||
@@ -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
|
||||||
@@ -2,3 +2,4 @@ __pycache__/
|
|||||||
.env
|
.env
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
static/dist/
|
static/dist/
|
||||||
|
venv/
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>"
|
||||||
|
|||||||
+3
-3
@@ -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>
|
||||||
|
|||||||
+1140
-95
File diff suppressed because it is too large
Load Diff
+186
-24
@@ -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; audit_log_enabled?: boolean }> {
|
||||||
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,20 +98,29 @@ 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);
|
||||||
return d.items;
|
return d.items;
|
||||||
},
|
},
|
||||||
|
|
||||||
async createHost(body: {
|
async createHost(body: Record<string, unknown>): Promise<{ id: number }> {
|
||||||
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", {
|
const res = await fetch("/api/hosts", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
@@ -117,14 +132,7 @@ export const api = {
|
|||||||
|
|
||||||
async patchHost(
|
async patchHost(
|
||||||
id: number,
|
id: number,
|
||||||
body: Partial<{
|
body: Record<string, unknown>,
|
||||||
label: string;
|
|
||||||
hostname: string;
|
|
||||||
port: number;
|
|
||||||
identity_id: number;
|
|
||||||
folder_id: number | null;
|
|
||||||
jump_host_id: number | null;
|
|
||||||
}>,
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const res = await fetch(`/api/hosts/${id}`, {
|
const res = await fetch(`/api/hosts/${id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
@@ -153,6 +161,25 @@ export const api = {
|
|||||||
return handle(res);
|
return handle(res);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async updateIdentity(
|
||||||
|
id: number,
|
||||||
|
body: Partial<{
|
||||||
|
label: string;
|
||||||
|
ssh_username: string;
|
||||||
|
password: string;
|
||||||
|
private_key: string;
|
||||||
|
key_passphrase: string;
|
||||||
|
}>,
|
||||||
|
): Promise<void> {
|
||||||
|
const res = await fetch(`/api/identities/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
credentials: "include",
|
||||||
|
headers: jsonHeaders,
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
await handle(res);
|
||||||
|
},
|
||||||
|
|
||||||
async deleteIdentity(id: number): Promise<void> {
|
async deleteIdentity(id: number): Promise<void> {
|
||||||
const res = await fetch(`/api/identities/${id}`, {
|
const res = await fetch(`/api/identities/${id}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
@@ -161,8 +188,48 @@ export const api = {
|
|||||||
await handle(res);
|
await handle(res);
|
||||||
},
|
},
|
||||||
|
|
||||||
async listConnectionAudit(limit = 200): Promise<ConnectionAuditRow[]> {
|
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[]> {
|
||||||
const q = new URLSearchParams({ limit: String(limit) });
|
const q = new URLSearchParams({ limit: String(limit) });
|
||||||
|
if (daysBack !== undefined) {
|
||||||
|
q.set("days_back", String(daysBack));
|
||||||
|
}
|
||||||
const res = await fetch(`/api/audit/connections?${q.toString()}`, {
|
const res = await fetch(`/api/audit/connections?${q.toString()}`, {
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
});
|
});
|
||||||
@@ -170,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,
|
||||||
@@ -217,16 +318,41 @@ export const api = {
|
|||||||
await handle(res);
|
await handle(res);
|
||||||
},
|
},
|
||||||
|
|
||||||
async sftpUpload(connId: string, path: string, file: File): Promise<void> {
|
async sftpUpload(
|
||||||
|
connId: string,
|
||||||
|
path: string,
|
||||||
|
file: File,
|
||||||
|
onProgress?: (loaded: number, total: number) => void
|
||||||
|
): Promise<void> {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.set("path", path);
|
fd.set("path", path);
|
||||||
fd.set("file", file);
|
fd.set("file", file);
|
||||||
const res = await fetch(`/api/sftp/${connId}/upload`, {
|
return new Promise((resolve, reject) => {
|
||||||
method: "POST",
|
const xhr = new XMLHttpRequest();
|
||||||
credentials: "include",
|
xhr.open("POST", `/api/sftp/${connId}/upload`);
|
||||||
body: fd,
|
xhr.withCredentials = true;
|
||||||
|
if (onProgress) {
|
||||||
|
xhr.upload.onprogress = (e) => {
|
||||||
|
if (e.lengthComputable) {
|
||||||
|
onProgress(e.loaded, e.total);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
xhr.onload = () => {
|
||||||
|
if (xhr.status === 401) return reject(new Error("unauthorized"));
|
||||||
|
let data: any = {};
|
||||||
|
try {
|
||||||
|
data = JSON.parse(xhr.responseText);
|
||||||
|
} catch (e) {}
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error(data.error || xhr.statusText));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
xhr.onerror = () => reject(new Error("Network Error"));
|
||||||
|
xhr.send(fd);
|
||||||
});
|
});
|
||||||
await handle(res);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
sftpDownloadUrl(connId: string, path: string): string {
|
sftpDownloadUrl(connId: string, path: string): string {
|
||||||
@@ -253,6 +379,8 @@ export interface HostRow {
|
|||||||
identity_label: string;
|
identity_label: string;
|
||||||
identity_auth_type: string;
|
identity_auth_type: string;
|
||||||
folder_label?: string | null;
|
folder_label?: string | null;
|
||||||
|
last_connected_at?: string | null;
|
||||||
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IdentityRow {
|
export interface IdentityRow {
|
||||||
@@ -261,6 +389,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;
|
||||||
@@ -279,3 +413,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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const err = ref("");
|
|||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const renameTarget = ref<SftpEntry | null>(null);
|
const renameTarget = ref<SftpEntry | null>(null);
|
||||||
const newName = ref("");
|
const newName = ref("");
|
||||||
|
const uploadProgress = ref<number | null>(null);
|
||||||
|
|
||||||
function isDir(m: number): boolean {
|
function isDir(m: number): boolean {
|
||||||
return (m & 0o170000) === 0o040000;
|
return (m & 0o170000) === 0o040000;
|
||||||
@@ -66,11 +67,16 @@ async function onUpload(ev: Event) {
|
|||||||
input.value = "";
|
input.value = "";
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
err.value = "";
|
err.value = "";
|
||||||
|
uploadProgress.value = 0;
|
||||||
try {
|
try {
|
||||||
await api.sftpUpload(props.connId, path.value, file);
|
await api.sftpUpload(props.connId, path.value, file, (loaded, total) => {
|
||||||
|
uploadProgress.value = Math.round((loaded / total) * 100);
|
||||||
|
});
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
err.value = e instanceof Error ? e.message : "Upload failed";
|
err.value = e instanceof Error ? e.message : "Upload failed";
|
||||||
|
} finally {
|
||||||
|
uploadProgress.value = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,7 +192,19 @@ function fmtSize(n: number): string {
|
|||||||
</div>
|
</div>
|
||||||
<div class="min-h-0 flex-1 overflow-auto p-2">
|
<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="err" class="mb-2 text-xs text-red-400">{{ err }}</p>
|
||||||
<p v-if="busy" class="text-xs text-slate-500">Loading…</p>
|
<div v-if="uploadProgress !== null" class="mb-2 space-y-1">
|
||||||
|
<div class="flex justify-between text-[10px] text-slate-400">
|
||||||
|
<span>Uploading...</span>
|
||||||
|
<span>{{ uploadProgress }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-1.5 w-full overflow-hidden rounded-full bg-slate-800">
|
||||||
|
<div
|
||||||
|
class="h-full bg-accent transition-all duration-200"
|
||||||
|
:style="{ width: uploadProgress + '%' }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else-if="busy" class="text-xs text-slate-500">Loading…</p>
|
||||||
<ul v-else class="space-y-0.5">
|
<ul v-else class="space-y-0.5">
|
||||||
<li
|
<li
|
||||||
v-for="e in entries"
|
v-for="e in entries"
|
||||||
|
|||||||
@@ -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')"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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>
|
||||||
@@ -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,6 +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 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:";
|
||||||
@@ -36,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 {
|
||||||
@@ -46,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();
|
||||||
@@ -69,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 }) => {
|
||||||
@@ -91,18 +132,7 @@ onMounted(async () => {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
@@ -121,9 +151,21 @@ onMounted(async () => {
|
|||||||
status.value = "Session ended";
|
status.value = "Session ended";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
visibilityHandler = () => {
|
||||||
|
if (document.visibilityState === "visible") {
|
||||||
|
sendPing();
|
||||||
|
fitAndResize();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("visibilitychange", visibilityHandler);
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
if (visibilityHandler) {
|
||||||
|
document.removeEventListener("visibilitychange", visibilityHandler);
|
||||||
|
visibilityHandler = null;
|
||||||
|
}
|
||||||
ro?.disconnect();
|
ro?.disconnect();
|
||||||
ro = null;
|
ro = null;
|
||||||
ws?.close();
|
ws?.close();
|
||||||
@@ -140,6 +182,7 @@ watch(
|
|||||||
await nextTick();
|
await nextTick();
|
||||||
fitAndResize();
|
fitAndResize();
|
||||||
term?.focus();
|
term?.focus();
|
||||||
|
sendPing();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -156,20 +199,22 @@ 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>
|
||||||
<div
|
<template v-if="showSftp">
|
||||||
v-if="connId"
|
<div
|
||||||
class="hidden w-80 shrink-0 flex-col border-l border-slate-800 md:flex"
|
v-if="connId"
|
||||||
>
|
class="hidden w-80 shrink-0 flex-col border-l border-slate-800 md:flex"
|
||||||
<SftpPanel :conn-id="connId" />
|
>
|
||||||
</div>
|
<SftpPanel :conn-id="connId" />
|
||||||
<div
|
</div>
|
||||||
v-else
|
<div
|
||||||
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"
|
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>
|
SFTP unlocks when the shell session is ready.
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
-6
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "ssh",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user