BIG BIG BIG BIG BIG UPDATES
Co-authored-by: Copilot <[email protected]>
This commit is contained in:
co-authored by
Copilot
Unverified
parent
46f7cd6a5f
commit
93ba8e9ac2
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.vite
|
||||
npm-debug.log
|
||||
@@ -0,0 +1,16 @@
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.29-alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>thePlug</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,22 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:3671/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
Generated
+1670
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "theplug-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview --host 0.0.0.0",
|
||||
"typecheck": "vue-tsc -b"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.20.1",
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/lint": "^6.9.5",
|
||||
"@codemirror/lsp-client": "^6.2.3",
|
||||
"@codemirror/search": "^6.7.0",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/view": "^6.41.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"vue": "^3.5.26"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.2.7",
|
||||
"vue-tsc": "^3.1.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, provide } from "vue";
|
||||
|
||||
import AppHeader from "./components/AppHeader.vue";
|
||||
import LoginView from "./components/LoginView.vue";
|
||||
import ProxyList from "./components/ProxyList.vue";
|
||||
import ProxyWorkbench from "./components/ProxyWorkbench.vue";
|
||||
import {
|
||||
controlPlaneKey,
|
||||
useControlPlane,
|
||||
} from "./composables/useControlPlane";
|
||||
|
||||
const plane = useControlPlane();
|
||||
provide(controlPlaneKey, plane);
|
||||
|
||||
onMounted(() => {
|
||||
if (plane.token) void plane.refreshAll();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LoginView
|
||||
v-if="!plane.token"
|
||||
v-model:username="plane.username"
|
||||
v-model:password="plane.password"
|
||||
:loading="plane.loading"
|
||||
:error="plane.error"
|
||||
@submit="plane.signIn"
|
||||
/>
|
||||
|
||||
<main v-else class="app-shell">
|
||||
<AppHeader
|
||||
:loading="plane.loading"
|
||||
@refresh="plane.refreshAll"
|
||||
@create="plane.newProxy"
|
||||
@logout="plane.signOut"
|
||||
/>
|
||||
|
||||
<div class="app-body">
|
||||
<ProxyList
|
||||
:proxies="plane.proxies"
|
||||
:selected-port="plane.selectedPort"
|
||||
@select="plane.selectedPort = $event"
|
||||
/>
|
||||
|
||||
<ProxyWorkbench />
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,109 @@
|
||||
import type {
|
||||
AddonExample,
|
||||
DockerService,
|
||||
HealthResult,
|
||||
MitmOption,
|
||||
PythonEditorSession,
|
||||
ProxyDocument,
|
||||
ProxyDraft,
|
||||
} from "./types";
|
||||
|
||||
const TOKEN_KEY = "theplug.token";
|
||||
export const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api";
|
||||
|
||||
export function getToken() {
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setToken(token: string | null) {
|
||||
if (token) {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
return;
|
||||
}
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function buildSocketUrl(path: string) {
|
||||
const token = getToken();
|
||||
const base = new URL(API_BASE, window.location.origin);
|
||||
base.protocol = base.protocol === "https:" ? "wss:" : "ws:";
|
||||
base.pathname = `${base.pathname.replace(/\/$/, "")}${path}`;
|
||||
if (token) {
|
||||
base.searchParams.set("token", token);
|
||||
}
|
||||
return base.toString();
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
const token = getToken();
|
||||
|
||||
if (token) {
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
if (init.body && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${path}`, { ...init, headers });
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
const body = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const detail = body?.detail ?? response.statusText;
|
||||
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
export async function login(username: string, password: string) {
|
||||
const response = await request<{ access_token: string }>("/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
setToken(response.access_token);
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
await request<void>("/auth/logout", { method: "POST" }).catch(() => undefined);
|
||||
setToken(null);
|
||||
}
|
||||
|
||||
export const api = {
|
||||
proxies: () => request<ProxyDocument[]>("/proxy"),
|
||||
services: () => request<DockerService[]>("/proxy/services"),
|
||||
options: () => request<MitmOption[]>("/proxy/mitmproxy/options"),
|
||||
examples: () => request<AddonExample[]>("/proxy/mitmproxy/examples"),
|
||||
editorSession: () =>
|
||||
request<PythonEditorSession>("/proxy/editor/python/session"),
|
||||
createExample: (payload: AddonExample) =>
|
||||
request<AddonExample>("/proxy/mitmproxy/examples", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
deleteExample: (id: string) =>
|
||||
request<void>(`/proxy/mitmproxy/examples/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
createProxy: (payload: ProxyDraft) =>
|
||||
request<ProxyDocument>("/proxy", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
updateProxy: (port: number, payload: Partial<ProxyDraft>) =>
|
||||
request<ProxyDocument>(`/proxy/${port}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
deleteProxy: (port: number) =>
|
||||
request<void>(`/proxy/${port}`, { method: "DELETE" }),
|
||||
start: (port: number) =>
|
||||
request<ProxyDocument>(`/proxy/${port}/start`, { method: "POST" }),
|
||||
stop: (port: number) =>
|
||||
request<ProxyDocument>(`/proxy/${port}/stop`, { method: "POST" }),
|
||||
restart: (port: number) =>
|
||||
request<ProxyDocument>(`/proxy/${port}/restart`, { method: "POST" }),
|
||||
health: (port: number) => request<HealthResult>(`/proxy/${port}/health`),
|
||||
logs: (port: number) => request<{ log: string }>(`/proxy/${port}/logs`),
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import AppLogo from "./AppLogo.vue";
|
||||
|
||||
defineProps<{
|
||||
loading: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: [];
|
||||
create: [];
|
||||
logout: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="app-header">
|
||||
<div class="brand">
|
||||
<AppLogo />
|
||||
<div>
|
||||
<strong>thePlug</strong>
|
||||
<small>proxy control plane</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-meta">
|
||||
<span class="team">mitmdump</span>
|
||||
<span v-if="loading" class="muted">applying changes</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="ghost" :disabled="loading" @click="emit('refresh')">Refresh</button>
|
||||
<button class="primary" @click="emit('create')">New</button>
|
||||
<button class="ghost" @click="emit('logout')">Logout</button>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg class="logo" viewBox="0 0 64 64" role="img" aria-label="thePlug logo">
|
||||
<rect x="10" y="10" width="44" height="44" rx="10" />
|
||||
<path d="M26 19v10M38 19v10" />
|
||||
<path d="M22 29h20v6a10 10 0 0 1-10 10 10 10 0 0 1-10-10v-6z" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
import { basicSetup, EditorView } from "codemirror";
|
||||
import { keymap } from "@codemirror/view";
|
||||
import { python } from "@codemirror/lang-python";
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
|
||||
import { api } from "../api";
|
||||
import { connectPythonLsp } from "../editor/pythonLsp";
|
||||
|
||||
const model = defineModel<string>({ required: true });
|
||||
const emit = defineEmits<{
|
||||
save: [];
|
||||
}>();
|
||||
const host = ref<HTMLElement | null>(null);
|
||||
const status = ref("Connecting Python tools...");
|
||||
const error = ref("");
|
||||
let editor: EditorView | null = null;
|
||||
let languageClient: Awaited<ReturnType<typeof connectPythonLsp>>["client"] | null =
|
||||
null;
|
||||
let languageSocket: WebSocket | null = null;
|
||||
|
||||
const editorTheme = EditorView.theme(
|
||||
{
|
||||
"&": {
|
||||
height: "100%",
|
||||
color: "#edf2f7",
|
||||
backgroundColor: "#0b0f12",
|
||||
fontSize: "14px",
|
||||
},
|
||||
".cm-scroller": {
|
||||
fontFamily: "IBM Plex Mono, JetBrains Mono, ui-monospace, monospace",
|
||||
lineHeight: "1.6",
|
||||
},
|
||||
".cm-content": {
|
||||
padding: "14px 0",
|
||||
caretColor: "#e7fbff",
|
||||
},
|
||||
".cm-gutters": {
|
||||
border: "none",
|
||||
backgroundColor: "#0b0f12",
|
||||
color: "#6f7c86",
|
||||
},
|
||||
".cm-activeLine": {
|
||||
backgroundColor: "#12181d",
|
||||
},
|
||||
".cm-activeLineGutter": {
|
||||
backgroundColor: "#12181d",
|
||||
},
|
||||
".cm-selectionBackground, &.cm-focused .cm-selectionBackground": {
|
||||
backgroundColor: "#1b2630",
|
||||
},
|
||||
".cm-tooltip": {
|
||||
border: "1px solid #24303a",
|
||||
backgroundColor: "#10151a",
|
||||
color: "#edf2f7",
|
||||
},
|
||||
".cm-panels": {
|
||||
backgroundColor: "#10151a",
|
||||
color: "#edf2f7",
|
||||
},
|
||||
".cm-searchMatch": {
|
||||
backgroundColor: "#23303b",
|
||||
outline: "1px solid #334455",
|
||||
},
|
||||
".cm-cursor": {
|
||||
borderLeftColor: "#e7fbff",
|
||||
},
|
||||
},
|
||||
{ dark: true },
|
||||
);
|
||||
|
||||
function emitSave() {
|
||||
emit("save");
|
||||
return true;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!host.value) return;
|
||||
void mountEditor();
|
||||
});
|
||||
|
||||
watch(model, (value) => {
|
||||
if (!editor) return;
|
||||
const current = editor.state.doc.toString();
|
||||
if (current === value) return;
|
||||
editor.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: current.length,
|
||||
insert: value,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
languageClient?.disconnect();
|
||||
languageClient = null;
|
||||
languageSocket?.close();
|
||||
languageSocket = null;
|
||||
editor?.destroy();
|
||||
editor = null;
|
||||
});
|
||||
|
||||
async function mountEditor() {
|
||||
if (!host.value) return;
|
||||
|
||||
try {
|
||||
error.value = "";
|
||||
const session = await api.editorSession();
|
||||
const connection = await connectPythonLsp(session);
|
||||
languageClient = connection.client;
|
||||
languageSocket = connection.socket;
|
||||
|
||||
void languageClient.initializing.then(
|
||||
() => {
|
||||
status.value = "Python tools ready";
|
||||
},
|
||||
() => {
|
||||
status.value = "Python tools unavailable";
|
||||
},
|
||||
);
|
||||
|
||||
editor = new EditorView({
|
||||
doc: model.value,
|
||||
extensions: [
|
||||
basicSetup,
|
||||
python(),
|
||||
editorTheme,
|
||||
keymap.of([{ key: "Mod-s", run: emitSave }]),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (!update.docChanged) return;
|
||||
const value = update.state.doc.toString();
|
||||
if (value !== model.value) model.value = value;
|
||||
}),
|
||||
languageClient.plugin(session.file_uri, "python"),
|
||||
],
|
||||
parent: host.value,
|
||||
});
|
||||
} catch (value) {
|
||||
const message =
|
||||
value instanceof Error ? value.message : "Unable to start the editor";
|
||||
error.value = message;
|
||||
status.value = "Python tools unavailable";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="code-editor-wrap">
|
||||
<div class="code-editor-status">
|
||||
<span>{{ status }}</span>
|
||||
<span v-if="error" class="field-error">{{ error }}</span>
|
||||
</div>
|
||||
<div ref="host" class="code-editor" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { defineAsyncComponent } from "vue";
|
||||
|
||||
import { useControlPlaneContext } from "../composables/useControlPlane";
|
||||
|
||||
const CodeEditor = defineAsyncComponent(() => import("./CodeEditor.vue"));
|
||||
const plane = useControlPlaneContext();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="editor-pane panel">
|
||||
<div class="panel-title">
|
||||
<div>
|
||||
<p class="eyebrow">Code</p>
|
||||
<h2>Addon editor</h2>
|
||||
</div>
|
||||
<div class="editor-meta">
|
||||
<span>{{ plane.codeLineCount }} lines</span>
|
||||
<span>{{ plane.draft.mode }} / {{ plane.draft.protocol }}</span>
|
||||
<button class="ghost" @click="plane.toggleEditor(false)">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-shell">
|
||||
<CodeEditor v-model="plane.draft.code" @save="plane.saveProxy" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { useControlPlaneContext } from "../composables/useControlPlane";
|
||||
|
||||
const plane = useControlPlaneContext();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel stack-panel tool-panel">
|
||||
<div class="panel-title">
|
||||
<div>
|
||||
<p class="eyebrow">Templates</p>
|
||||
<h2>Examples</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="inline-grid">
|
||||
<input
|
||||
v-model="plane.customExampleName"
|
||||
placeholder="Save current code as..."
|
||||
/>
|
||||
<button @click="plane.saveCurrentAsExample">Save</button>
|
||||
</div>
|
||||
|
||||
<div class="example-shell">
|
||||
<div class="example-browser">
|
||||
<input v-model="plane.exampleSearch" placeholder="Search examples" />
|
||||
|
||||
<div class="example-index">
|
||||
<button
|
||||
v-for="example in plane.filteredExamples"
|
||||
:key="example.id"
|
||||
class="example-entry"
|
||||
:class="{ selected: plane.selectedExample?.id === example.id }"
|
||||
@click="plane.selectExample(example.id)"
|
||||
>
|
||||
<strong>{{ example.name }}</strong>
|
||||
<small>{{ example.category }}</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article v-if="plane.selectedExample" class="example-preview">
|
||||
<div class="option-doc-head">
|
||||
<div>
|
||||
<p class="eyebrow">Selected</p>
|
||||
<h3>{{ plane.selectedExample.name }}</h3>
|
||||
</div>
|
||||
<div class="template-meta">
|
||||
<span class="type-chip">{{ plane.selectedExample.category }}</span>
|
||||
<span class="type-chip">
|
||||
{{ plane.selectedExample.built_in ? "built-in" : "custom" }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="doc-copy">{{ plane.selectedExample.description }}</p>
|
||||
|
||||
<div class="doc-actions">
|
||||
<button @click="plane.applyExample(plane.selectedExample)">Use template</button>
|
||||
<a
|
||||
v-if="plane.selectedExample.source_url"
|
||||
:href="plane.selectedExample.source_url"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Source
|
||||
</a>
|
||||
<button
|
||||
v-if="!plane.selectedExample.built_in"
|
||||
class="ghost"
|
||||
@click="plane.removeExample(plane.selectedExample.id)"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
import { protocols, useControlPlaneContext } from "../composables/useControlPlane";
|
||||
|
||||
const plane = useControlPlaneContext();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel stack-panel compact-panel">
|
||||
<div class="panel-title">
|
||||
<div>
|
||||
<p class="eyebrow">Health</p>
|
||||
<h2>Checks</h2>
|
||||
</div>
|
||||
<button :disabled="!plane.selectedProxy" @click="plane.checkHealth">
|
||||
Run
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="metric-grid">
|
||||
<article class="metric-card">
|
||||
<span class="metric-label">State</span>
|
||||
<strong>{{ plane.draft.healthcheck.enabled ? "Enabled" : "Disabled" }}</strong>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span class="metric-label">Path</span>
|
||||
<strong>{{ plane.draft.healthcheck.path }}</strong>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span class="metric-label">Method</span>
|
||||
<strong>{{ plane.draft.healthcheck.method }}</strong>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span class="metric-label">Timeout</span>
|
||||
<strong>{{ plane.draft.healthcheck.timeout_seconds }}s</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<details class="compact-details">
|
||||
<summary>Advanced check settings</summary>
|
||||
|
||||
<div class="form-grid tight-grid">
|
||||
<label class="checkbox-row">
|
||||
<input v-model="plane.draft.healthcheck.enabled" type="checkbox" />
|
||||
<span>Enabled</span>
|
||||
</label>
|
||||
<label>
|
||||
Protocol override
|
||||
<select v-model="plane.draft.healthcheck.protocol">
|
||||
<option :value="null">Use proxy protocol</option>
|
||||
<option v-for="protocol in protocols" :key="protocol" :value="protocol">
|
||||
{{ protocol }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Method
|
||||
<select v-model="plane.draft.healthcheck.method">
|
||||
<option>GET</option>
|
||||
<option>HEAD</option>
|
||||
<option>POST</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Path
|
||||
<input v-model="plane.draft.healthcheck.path" />
|
||||
</label>
|
||||
<label>
|
||||
Expected status
|
||||
<input
|
||||
v-model.number="plane.draft.healthcheck.expected_status"
|
||||
type="number"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Timeout
|
||||
<input
|
||||
v-model.number="plane.draft.healthcheck.timeout_seconds"
|
||||
type="number"
|
||||
step="0.5"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
UDP payload
|
||||
<input
|
||||
v-model="plane.draft.healthcheck.udp_payload"
|
||||
placeholder="used for udp-like checks"
|
||||
/>
|
||||
</label>
|
||||
<label class="checkbox-row">
|
||||
<input v-model="plane.draft.healthcheck.expect_response" type="checkbox" />
|
||||
<span>Expect response</span>
|
||||
</label>
|
||||
<label class="checkbox-row">
|
||||
<input v-model="plane.draft.healthcheck.verify_tls" type="checkbox" />
|
||||
<span>Verify TLS</span>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div v-if="plane.health" class="health-box" :class="plane.health.overall">
|
||||
<strong>{{ plane.health.overall }}</strong>
|
||||
<div class="health-list">
|
||||
<div v-for="check in plane.health.checks" :key="check.target" class="health-row">
|
||||
<span>{{ check.target }}</span>
|
||||
<span>{{ check.protocol }}</span>
|
||||
<span>{{ check.healthy ? "ok" : check.error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import AppLogo from "./AppLogo.vue";
|
||||
|
||||
defineProps<{
|
||||
loading: boolean;
|
||||
error: string;
|
||||
}>();
|
||||
|
||||
const username = defineModel<string>("username", { required: true });
|
||||
const password = defineModel<string>("password", { required: true });
|
||||
const emit = defineEmits<{
|
||||
submit: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="login-screen">
|
||||
<section class="login-card">
|
||||
<div class="login-brand">
|
||||
<AppLogo />
|
||||
<div>
|
||||
<p class="eyebrow">thePlug</p>
|
||||
<h1>Proxy control plane</h1>
|
||||
<p class="muted">Manage routes, runtime state, and mitmdump addons from one console.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form @submit.prevent="emit('submit')">
|
||||
<label>Username<input v-model="username" autocomplete="username" /></label>
|
||||
<label>Password<input v-model="password" type="password" autocomplete="current-password" /></label>
|
||||
<button class="primary" :disabled="loading">Login</button>
|
||||
</form>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { useControlPlaneContext } from "../composables/useControlPlane";
|
||||
|
||||
const plane = useControlPlaneContext();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel stack-panel tool-panel logs-panel">
|
||||
<div class="panel-title">
|
||||
<div>
|
||||
<p class="eyebrow">Output</p>
|
||||
<h2>Logs</h2>
|
||||
</div>
|
||||
<button :disabled="!plane.selectedProxy" @click="plane.loadLogs">Load</button>
|
||||
</div>
|
||||
|
||||
<pre class="terminal">{{ plane.logs || "No logs loaded." }}</pre>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
import type { MitmOption } from "../types";
|
||||
import { useControlPlaneContext } from "../composables/useControlPlane";
|
||||
|
||||
const plane = useControlPlaneContext();
|
||||
|
||||
function isBool(option?: MitmOption | null) {
|
||||
return option?.type === "bool";
|
||||
}
|
||||
|
||||
function isNumber(option?: MitmOption | null) {
|
||||
return option?.type === "int" || option?.type === "optional int";
|
||||
}
|
||||
|
||||
function isSequence(option?: MitmOption | null) {
|
||||
return option?.type === "sequence of str";
|
||||
}
|
||||
|
||||
function formatDefault(option?: MitmOption | null) {
|
||||
if (!option) return "n/a";
|
||||
if (Array.isArray(option.default)) {
|
||||
return option.default.length ? option.default.join(", ") : "[]";
|
||||
}
|
||||
if (option.default === null || option.default === "") return "none";
|
||||
return String(option.default);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel stack-panel tool-panel">
|
||||
<div class="panel-title">
|
||||
<div>
|
||||
<p class="eyebrow">Mitmdump</p>
|
||||
<h2>Options</h2>
|
||||
</div>
|
||||
<span class="counter">{{ plane.optionCount }}</span>
|
||||
</div>
|
||||
|
||||
<div class="active-options compact-table">
|
||||
<div class="table-head">
|
||||
<span>Active</span>
|
||||
<span>Value</span>
|
||||
<span />
|
||||
</div>
|
||||
<div v-if="plane.activeOptions.length" class="table-body">
|
||||
<div v-for="item in plane.activeOptions" :key="item.name" class="table-row">
|
||||
<button class="table-link" @click="plane.selectOption(item.name)">
|
||||
{{ item.name }}
|
||||
</button>
|
||||
<label v-if="isBool(item.option)" class="inline-toggle">
|
||||
<input
|
||||
:checked="item.value === 'true'"
|
||||
type="checkbox"
|
||||
@change="
|
||||
plane.updateOption(
|
||||
item.name,
|
||||
String(($event.target as HTMLInputElement).checked),
|
||||
item.option,
|
||||
)
|
||||
"
|
||||
/>
|
||||
<span>{{ item.value }}</span>
|
||||
</label>
|
||||
<input
|
||||
v-else
|
||||
:value="item.value"
|
||||
:type="isNumber(item.option) ? 'number' : 'text'"
|
||||
:placeholder="isSequence(item.option) ? 'comma separated values' : ''"
|
||||
@input="
|
||||
plane.updateOption(
|
||||
item.name,
|
||||
($event.target as HTMLInputElement).value,
|
||||
item.option,
|
||||
)
|
||||
"
|
||||
/>
|
||||
<button class="ghost" @click="plane.removeOption(item.name)">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="muted small">No active options.</p>
|
||||
</div>
|
||||
|
||||
<div class="option-shell">
|
||||
<div class="option-browser">
|
||||
<input
|
||||
v-model="plane.optionSearch"
|
||||
placeholder="Search options"
|
||||
/>
|
||||
|
||||
<div class="option-table">
|
||||
<button
|
||||
v-for="option in plane.filteredOptions"
|
||||
:key="option.name"
|
||||
class="option-entry"
|
||||
:class="{ selected: plane.selectedOption?.name === option.name }"
|
||||
@click="plane.selectOption(option.name)"
|
||||
>
|
||||
<strong>{{ option.name }}</strong>
|
||||
<small>{{ option.type }}</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article v-if="plane.selectedOption" class="option-doc">
|
||||
<div class="option-doc-head">
|
||||
<div>
|
||||
<p class="eyebrow">Reference</p>
|
||||
<h3>{{ plane.selectedOption.name }}</h3>
|
||||
</div>
|
||||
<span class="type-chip">{{ plane.selectedOption.type }}</span>
|
||||
</div>
|
||||
|
||||
<p class="doc-copy">{{ plane.selectedOption.description }}</p>
|
||||
|
||||
<div class="doc-facts">
|
||||
<span>Default: {{ formatDefault(plane.selectedOption) }}</span>
|
||||
<span v-if="plane.selectedOption.choices.length">
|
||||
Choices: {{ plane.selectedOption.choices.join(", ") }}
|
||||
</span>
|
||||
<span v-if="plane.selectedOption.reserved">Managed by thePlug</span>
|
||||
</div>
|
||||
|
||||
<div class="doc-actions">
|
||||
<button
|
||||
:disabled="plane.selectedOption.reserved"
|
||||
@click="plane.setOption(plane.selectedOption)"
|
||||
>
|
||||
Add option
|
||||
</button>
|
||||
<a
|
||||
v-if="plane.selectedOption.docs_url"
|
||||
:href="plane.selectedOption.docs_url"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Official docs
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import type { ProxyDocument } from "../types";
|
||||
import StatusPill from "./StatusPill.vue";
|
||||
|
||||
defineProps<{
|
||||
proxies: ProxyDocument[];
|
||||
selectedPort: number | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [port: number];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="rail panel">
|
||||
<div class="panel-title">
|
||||
<div>
|
||||
<p class="eyebrow">Inventory</p>
|
||||
<h2>Proxies</h2>
|
||||
</div>
|
||||
<span class="counter">{{ proxies.length }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!proxies.length" class="empty-state">
|
||||
<strong>No proxies configured</strong>
|
||||
<span>Create a draft, save it, then start it when you want mitmdump to own the route.</span>
|
||||
</div>
|
||||
|
||||
<div class="proxy-list">
|
||||
<button
|
||||
v-for="proxy in proxies"
|
||||
:key="proxy.proxy_port"
|
||||
class="proxy-card"
|
||||
:class="{ selected: proxy.proxy_port === selectedPort }"
|
||||
@click="emit('select', proxy.proxy_port)"
|
||||
>
|
||||
<div class="proxy-card-head">
|
||||
<strong>{{ proxy.name }}</strong>
|
||||
<StatusPill :status="proxy.status" />
|
||||
</div>
|
||||
<div class="proxy-meta">
|
||||
<span>:{{ proxy.proxy_port }}</span>
|
||||
<span>{{ proxy.mode }} / {{ proxy.protocol }}</span>
|
||||
</div>
|
||||
<span class="proxy-route">
|
||||
{{ proxy.service.host }}:{{ proxy.service.port }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import { useControlPlaneContext } from "../composables/useControlPlane";
|
||||
import EditorPane from "./EditorPane.vue";
|
||||
import ExamplesPanel from "./ExamplesPanel.vue";
|
||||
import HealthPanel from "./HealthPanel.vue";
|
||||
import LogsPanel from "./LogsPanel.vue";
|
||||
import OptionsPanel from "./OptionsPanel.vue";
|
||||
import RuntimePanel from "./RuntimePanel.vue";
|
||||
import ServicePanel from "./ServicePanel.vue";
|
||||
import StatusPill from "./StatusPill.vue";
|
||||
|
||||
const plane = useControlPlaneContext();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="workbench">
|
||||
<header class="workbench-head panel">
|
||||
<div class="workbench-copy">
|
||||
<p class="eyebrow">Workspace</p>
|
||||
<h1>{{ plane.draft.name || "Untitled proxy" }}</h1>
|
||||
<div class="route-row">
|
||||
<span class="route-token">{{ plane.routeState.configuredListen }}</span>
|
||||
<span class="route-arrow">to</span>
|
||||
<span class="route-target">{{ plane.routeState.configuredTarget }}</span>
|
||||
<StatusPill
|
||||
v-if="plane.selectedProxy"
|
||||
:status="plane.selectedProxy.status"
|
||||
/>
|
||||
</div>
|
||||
<p class="summary-copy">{{ plane.routeState.startNotice }}</p>
|
||||
<p v-if="plane.selectedProxy?.last_error" class="error compact">
|
||||
{{ plane.selectedProxy.last_error }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="action-row">
|
||||
<button @click="plane.saveProxy" :disabled="plane.loading">Save</button>
|
||||
<button
|
||||
@click="plane.runtimeAction('start')"
|
||||
:disabled="!plane.selectedProxy || plane.loading"
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
<button
|
||||
@click="plane.runtimeAction('stop')"
|
||||
:disabled="!plane.selectedProxy || plane.loading"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
<button
|
||||
@click="plane.runtimeAction('restart')"
|
||||
:disabled="!plane.selectedProxy || plane.loading"
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
<button class="ghost" @click="plane.toggleEditor()">
|
||||
{{ plane.editorOpen ? "Hide editor" : "Open editor" }}
|
||||
</button>
|
||||
<button
|
||||
class="danger"
|
||||
@click="plane.removeProxy"
|
||||
:disabled="!plane.selectedProxy || plane.loading"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p v-if="plane.error" class="error">{{ plane.error }}</p>
|
||||
<p v-if="plane.notice" class="notice">{{ plane.notice }}</p>
|
||||
|
||||
<section class="summary-grid">
|
||||
<article class="panel summary-panel">
|
||||
<div class="panel-title">
|
||||
<div>
|
||||
<p class="eyebrow">Route</p>
|
||||
<h2>Runtime mapping</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metric-grid">
|
||||
<article class="metric-card">
|
||||
<span class="metric-label">Listen</span>
|
||||
<strong>{{ plane.routeState.configuredListen }}</strong>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span class="metric-label">Configured upstream</span>
|
||||
<strong>{{ plane.routeState.configuredTarget }}</strong>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span class="metric-label">Live upstream</span>
|
||||
<strong>{{ plane.routeState.liveTarget }}</strong>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span class="metric-label">Mode</span>
|
||||
<strong>{{ plane.routeState.modeLabel }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
<p class="inline-note">{{ plane.routeState.liveNotice }}</p>
|
||||
</article>
|
||||
|
||||
<article class="panel summary-panel">
|
||||
<div class="panel-title">
|
||||
<div>
|
||||
<p class="eyebrow">Behaviour</p>
|
||||
<h2>What start does</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-lines">
|
||||
<span>Service source: {{ plane.routeState.serviceLabel }}</span>
|
||||
<span>Save never remaps upstream ports.</span>
|
||||
<span>Start is the only action that applies runtime routing.</span>
|
||||
<span v-if="plane.routeState.remapped">
|
||||
This proxy is currently holding the original public service port.
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div class="workbench-grid">
|
||||
<div class="overview-stack">
|
||||
<RuntimePanel />
|
||||
<ServicePanel />
|
||||
<HealthPanel />
|
||||
</div>
|
||||
|
||||
<section class="utility-panel">
|
||||
<div class="tool-switcher">
|
||||
<button
|
||||
:class="{ active: plane.toolView === 'options' }"
|
||||
@click="plane.setToolView('options')"
|
||||
>
|
||||
Options
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: plane.toolView === 'examples' }"
|
||||
@click="plane.setToolView('examples')"
|
||||
>
|
||||
Examples
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: plane.toolView === 'logs' }"
|
||||
@click="plane.setToolView('logs')"
|
||||
>
|
||||
Logs
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<OptionsPanel v-if="plane.toolView === 'options'" />
|
||||
<ExamplesPanel v-else-if="plane.toolView === 'examples'" />
|
||||
<LogsPanel v-else />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<EditorPane v-if="plane.editorOpen" />
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { modes, protocols, useControlPlaneContext } from "../composables/useControlPlane";
|
||||
|
||||
const plane = useControlPlaneContext();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel stack-panel compact-panel">
|
||||
<div class="panel-title">
|
||||
<div>
|
||||
<p class="eyebrow">Runtime</p>
|
||||
<h2>Proxy settings</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-grid tight-grid">
|
||||
<label>
|
||||
Name
|
||||
<input v-model="plane.draft.name" />
|
||||
</label>
|
||||
<label>
|
||||
Proxy port
|
||||
<input v-model.number="plane.draft.proxy_port" type="number" />
|
||||
</label>
|
||||
<label>
|
||||
Mode
|
||||
<select v-model="plane.draft.mode">
|
||||
<option v-for="mode in modes" :key="mode">{{ mode }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Protocol
|
||||
<select v-model="plane.draft.protocol">
|
||||
<option v-for="protocol in protocols" :key="protocol">{{ protocol }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Listen host
|
||||
<input v-model="plane.draft.listen_host" />
|
||||
</label>
|
||||
<label>
|
||||
Mode spec
|
||||
<input
|
||||
v-model="plane.draft.mode_spec"
|
||||
placeholder="optional raw mitmproxy mode"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p class="inline-note">
|
||||
Save stores this configuration. Start launches mitmdump with the selected mode.
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { useControlPlaneContext } from "../composables/useControlPlane";
|
||||
|
||||
const plane = useControlPlaneContext();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="panel stack-panel compact-panel">
|
||||
<div class="panel-title">
|
||||
<div>
|
||||
<p class="eyebrow">Upstream</p>
|
||||
<h2>Service route</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metric-grid">
|
||||
<article class="metric-card">
|
||||
<span class="metric-label">Configured target</span>
|
||||
<strong>{{ plane.routeState.configuredTarget }}</strong>
|
||||
</article>
|
||||
<article class="metric-card">
|
||||
<span class="metric-label">Live upstream</span>
|
||||
<strong>{{ plane.routeState.liveTarget }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="form-grid tight-grid">
|
||||
<label>
|
||||
Host
|
||||
<input v-model="plane.draft.service.host" />
|
||||
</label>
|
||||
<label>
|
||||
Port
|
||||
<input v-model.number="plane.draft.service.port" type="number" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p class="inline-note">{{ plane.routeState.liveNotice }}</p>
|
||||
|
||||
<div v-if="plane.services.length" class="service-grid">
|
||||
<div
|
||||
v-for="service in plane.services"
|
||||
:key="service.name + service.folder"
|
||||
class="service-chip"
|
||||
>
|
||||
<strong>{{ service.name }}</strong>
|
||||
<small>{{ service.folder }}</small>
|
||||
<div class="port-row">
|
||||
<button
|
||||
v-for="port in service.ports"
|
||||
:key="port"
|
||||
class="port-chip"
|
||||
:class="{
|
||||
active:
|
||||
plane.draft.service.name === service.name &&
|
||||
plane.draft.service.folder === service.folder &&
|
||||
plane.draft.service.port === port,
|
||||
}"
|
||||
@click="plane.applyService(service, port)"
|
||||
>
|
||||
{{ port }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="muted small">No Docker services detected by the backend.</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
status: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="status-pill" :class="status">{{ status }}</span>
|
||||
</template>
|
||||
@@ -0,0 +1,571 @@
|
||||
import {
|
||||
computed,
|
||||
inject,
|
||||
proxyRefs,
|
||||
reactive,
|
||||
ref,
|
||||
watch,
|
||||
type InjectionKey,
|
||||
} from "vue";
|
||||
|
||||
import { api, getToken, login, logout } from "../api";
|
||||
import type {
|
||||
AddonExample,
|
||||
DockerService,
|
||||
HealthResult,
|
||||
MitmOption,
|
||||
ProxyDocument,
|
||||
ProxyDraft,
|
||||
} from "../types";
|
||||
|
||||
const editorStorageKey = "theplug.editor.open";
|
||||
const toolStorageKey = "theplug.tool.view";
|
||||
|
||||
export const modes = [
|
||||
"reverse",
|
||||
"regular",
|
||||
"socks5",
|
||||
"upstream",
|
||||
"wireguard",
|
||||
"transparent",
|
||||
"local",
|
||||
"tun",
|
||||
"dns",
|
||||
];
|
||||
|
||||
export const protocols = [
|
||||
"http",
|
||||
"https",
|
||||
"tcp",
|
||||
"tls",
|
||||
"udp",
|
||||
"dtls",
|
||||
"dns",
|
||||
"http3",
|
||||
"quic",
|
||||
];
|
||||
|
||||
export type RuntimeAction = "start" | "stop" | "restart";
|
||||
export type ToolView = "options" | "examples" | "logs";
|
||||
|
||||
export function createDefaultDraft(port = 8080, code?: string): ProxyDraft {
|
||||
return {
|
||||
proxy_port: port,
|
||||
name: "new-proxy",
|
||||
code:
|
||||
code ??
|
||||
`from mitmproxy import http
|
||||
|
||||
|
||||
def request(flow: http.HTTPFlow) -> None:
|
||||
pass
|
||||
`,
|
||||
mode: "reverse",
|
||||
mode_spec: null,
|
||||
protocol: "http",
|
||||
listen_host: "0.0.0.0",
|
||||
mitm_options: {
|
||||
flow_detail: 1,
|
||||
termlog_verbosity: "info",
|
||||
},
|
||||
healthcheck: {
|
||||
enabled: true,
|
||||
protocol: null,
|
||||
path: "/",
|
||||
method: "GET",
|
||||
expected_status: null,
|
||||
udp_payload: "",
|
||||
expect_response: true,
|
||||
verify_tls: false,
|
||||
timeout_seconds: 2,
|
||||
},
|
||||
service: {
|
||||
host: "127.0.0.1",
|
||||
port: 9000,
|
||||
name: null,
|
||||
folder: null,
|
||||
upstream_port: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readSession(key: string, fallback: string) {
|
||||
if (typeof window === "undefined") return fallback;
|
||||
return window.sessionStorage.getItem(key) ?? fallback;
|
||||
}
|
||||
|
||||
function writeSession(key: string, value: string) {
|
||||
if (typeof window === "undefined") return;
|
||||
window.sessionStorage.setItem(key, value);
|
||||
}
|
||||
|
||||
export function useControlPlane() {
|
||||
const token = ref(getToken());
|
||||
const username = ref("admin");
|
||||
const password = ref("");
|
||||
const error = ref("");
|
||||
const notice = ref("");
|
||||
const loading = ref(false);
|
||||
const proxies = ref<ProxyDocument[]>([]);
|
||||
const services = ref<DockerService[]>([]);
|
||||
const options = ref<MitmOption[]>([]);
|
||||
const examples = ref<AddonExample[]>([]);
|
||||
const selectedPort = ref<number | null>(null);
|
||||
const health = ref<HealthResult | null>(null);
|
||||
const logs = ref("");
|
||||
const optionSearch = ref("");
|
||||
const exampleSearch = ref("");
|
||||
const customExampleName = ref("");
|
||||
const editorOpen = ref(readSession(editorStorageKey, "0") === "1");
|
||||
const toolView = ref<ToolView>(
|
||||
readSession(toolStorageKey, "options") as ToolView,
|
||||
);
|
||||
const selectedOptionName = ref("");
|
||||
const selectedExampleId = ref("");
|
||||
const draft = reactive<ProxyDraft>(createDefaultDraft());
|
||||
|
||||
const selectedProxy = computed(() =>
|
||||
proxies.value.find((proxy) => proxy.proxy_port === selectedPort.value),
|
||||
);
|
||||
|
||||
const routeState = computed(() => {
|
||||
const livePort =
|
||||
selectedProxy.value?.service.upstream_port ?? draft.service.upstream_port ?? null;
|
||||
const dockerManaged = Boolean(draft.service.name && draft.service.folder);
|
||||
const reverseManaged = draft.mode === "reverse" && !draft.mode_spec;
|
||||
const remapped = livePort !== null && livePort !== draft.service.port;
|
||||
const configuredTarget = `${draft.service.host}:${draft.service.port}`;
|
||||
const liveTarget = `${draft.service.host}:${livePort ?? draft.service.port}`;
|
||||
|
||||
let startNotice = "Save stores configuration only. Start applies runtime routing.";
|
||||
if (reverseManaged && dockerManaged) {
|
||||
startNotice =
|
||||
`Starting this reverse proxy will move the service from :${draft.service.port} ` +
|
||||
`to a temporary upstream port and bind mitmdump on :${draft.proxy_port}.`;
|
||||
} else if (draft.mode_spec) {
|
||||
startNotice =
|
||||
"This proxy uses a raw mode specification. Start will follow the custom mitmdump mode exactly as configured.";
|
||||
} else if (draft.mode !== "reverse") {
|
||||
startNotice =
|
||||
"This mode does not use Docker port remapping. Start only launches the configured mitmdump listener.";
|
||||
}
|
||||
|
||||
let liveNotice = "No runtime remap is active.";
|
||||
if (remapped) {
|
||||
liveNotice = `Live upstream is currently ${liveTarget}. Stop restores :${draft.service.port}.`;
|
||||
} else if (reverseManaged && dockerManaged) {
|
||||
liveNotice = "The original service port stays unchanged until Start is executed.";
|
||||
}
|
||||
|
||||
return {
|
||||
configuredListen: `${draft.listen_host}:${draft.proxy_port}`,
|
||||
configuredTarget,
|
||||
liveTarget,
|
||||
modeLabel: draft.mode_spec ?? `${draft.mode} / ${draft.protocol}`,
|
||||
serviceLabel: draft.service.name ?? "manual upstream",
|
||||
dockerManaged,
|
||||
reverseManaged,
|
||||
remapped,
|
||||
livePort,
|
||||
startNotice,
|
||||
liveNotice,
|
||||
};
|
||||
});
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
const query = optionSearch.value.toLowerCase().trim();
|
||||
const available = options.value.filter((option) => !option.reserved);
|
||||
const filtered = available
|
||||
.filter((option) => {
|
||||
if (!query) return true;
|
||||
return `${option.name} ${option.description}`.toLowerCase().includes(query);
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return filtered.slice(0, query ? 80 : 36);
|
||||
});
|
||||
|
||||
const optionCount = computed(
|
||||
() => options.value.filter((option) => !option.reserved).length,
|
||||
);
|
||||
|
||||
const selectedOption = computed(
|
||||
() =>
|
||||
filteredOptions.value.find((option) => option.name === selectedOptionName.value) ??
|
||||
filteredOptions.value[0] ??
|
||||
null,
|
||||
);
|
||||
|
||||
const filteredExamples = computed(() => {
|
||||
const query = exampleSearch.value.toLowerCase().trim();
|
||||
const filtered = examples.value.filter((example) => {
|
||||
if (!query) return true;
|
||||
return `${example.name} ${example.category} ${example.description}`
|
||||
.toLowerCase()
|
||||
.includes(query);
|
||||
});
|
||||
const sorted = filtered.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return query ? sorted : sorted.slice(0, 24);
|
||||
});
|
||||
|
||||
const selectedExample = computed(
|
||||
() =>
|
||||
filteredExamples.value.find((example) => example.id === selectedExampleId.value) ??
|
||||
filteredExamples.value[0] ??
|
||||
null,
|
||||
);
|
||||
|
||||
const activeOptions = computed(() =>
|
||||
Object.entries(draft.mitm_options)
|
||||
.map(([name, value]) => ({
|
||||
name,
|
||||
value: Array.isArray(value) ? value.join(", ") : String(value ?? ""),
|
||||
option: options.value.find((item) => item.name === name),
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
);
|
||||
|
||||
const codeLineCount = computed(() => draft.code.split("\n").length);
|
||||
|
||||
watch(selectedProxy, (proxy) => {
|
||||
if (!proxy) return;
|
||||
Object.assign(draft, structuredClone(proxy));
|
||||
health.value = null;
|
||||
logs.value = "";
|
||||
});
|
||||
|
||||
watch(editorOpen, (value) => {
|
||||
writeSession(editorStorageKey, value ? "1" : "0");
|
||||
});
|
||||
|
||||
watch(toolView, (value) => {
|
||||
writeSession(toolStorageKey, value);
|
||||
});
|
||||
|
||||
watch(filteredOptions, (items) => {
|
||||
if (!items.some((item) => item.name === selectedOptionName.value)) {
|
||||
selectedOptionName.value = items[0]?.name ?? "";
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
watch(filteredExamples, (items) => {
|
||||
if (!items.some((item) => item.id === selectedExampleId.value)) {
|
||||
selectedExampleId.value = items[0]?.id ?? "";
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
async function signIn() {
|
||||
await run(async () => {
|
||||
await login(username.value, password.value);
|
||||
token.value = getToken();
|
||||
password.value = "";
|
||||
await refreshAll();
|
||||
}, "Logged in");
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
await logout();
|
||||
token.value = null;
|
||||
proxies.value = [];
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
await run(async () => {
|
||||
const [proxyList, serviceList, optionList, exampleList] = await Promise.all([
|
||||
api.proxies(),
|
||||
api.services(),
|
||||
api.options(),
|
||||
api.examples(),
|
||||
]);
|
||||
proxies.value = proxyList;
|
||||
services.value = serviceList;
|
||||
options.value = optionList;
|
||||
examples.value = exampleList;
|
||||
if (!selectedPort.value && proxyList[0]) {
|
||||
selectedPort.value = proxyList[0].proxy_port;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function newProxy() {
|
||||
selectedPort.value = null;
|
||||
health.value = null;
|
||||
logs.value = "";
|
||||
Object.assign(
|
||||
draft,
|
||||
createDefaultDraft(nextPort(), examples.value[0]?.code ?? draft.code),
|
||||
);
|
||||
notice.value = "New proxy draft created. Save stores configuration only.";
|
||||
toolView.value = "options";
|
||||
}
|
||||
|
||||
async function saveProxy() {
|
||||
await run(async () => {
|
||||
const payload = cleanDraft();
|
||||
const saved = selectedProxy.value
|
||||
? await api.updateProxy(selectedProxy.value.proxy_port, payload)
|
||||
: await api.createProxy(payload);
|
||||
await refreshAll();
|
||||
selectedPort.value = saved.proxy_port;
|
||||
}, "Proxy saved");
|
||||
}
|
||||
|
||||
async function removeProxy() {
|
||||
const proxy = selectedProxy.value;
|
||||
if (!proxy) return;
|
||||
await run(async () => {
|
||||
await api.deleteProxy(proxy.proxy_port);
|
||||
selectedPort.value = null;
|
||||
await refreshAll();
|
||||
}, "Proxy deleted");
|
||||
}
|
||||
|
||||
async function runtimeAction(kind: RuntimeAction) {
|
||||
const proxy = selectedProxy.value;
|
||||
if (!proxy) return;
|
||||
await run(async () => {
|
||||
const port = proxy.proxy_port;
|
||||
const updated =
|
||||
kind === "start"
|
||||
? await api.start(port)
|
||||
: kind === "stop"
|
||||
? await api.stop(port)
|
||||
: await api.restart(port);
|
||||
replaceProxy(updated);
|
||||
selectedPort.value = updated.proxy_port;
|
||||
}, `Proxy ${kind} requested`);
|
||||
}
|
||||
|
||||
async function checkHealth() {
|
||||
const proxy = selectedProxy.value;
|
||||
if (!proxy) return;
|
||||
await run(async () => {
|
||||
health.value = await api.health(proxy.proxy_port);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
const proxy = selectedProxy.value;
|
||||
if (!proxy) return;
|
||||
await run(async () => {
|
||||
logs.value = (await api.logs(proxy.proxy_port)).log;
|
||||
});
|
||||
}
|
||||
|
||||
async function saveCurrentAsExample() {
|
||||
const name = customExampleName.value.trim();
|
||||
if (!name) {
|
||||
error.value = "Custom example name is required";
|
||||
return;
|
||||
}
|
||||
const id =
|
||||
name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "") ||
|
||||
`example-${Date.now()}`;
|
||||
await run(async () => {
|
||||
await api.createExample({
|
||||
id,
|
||||
name,
|
||||
description: `Captured from ${draft.mode} ${draft.protocol} proxy ${draft.name}.`,
|
||||
category: "custom",
|
||||
code: draft.code,
|
||||
source_url: null,
|
||||
built_in: false,
|
||||
});
|
||||
examples.value = await api.examples();
|
||||
customExampleName.value = "";
|
||||
selectedExampleId.value = id;
|
||||
}, "Custom example saved");
|
||||
}
|
||||
|
||||
async function removeExample(id: string) {
|
||||
await run(async () => {
|
||||
await api.deleteExample(id);
|
||||
examples.value = await api.examples();
|
||||
}, "Custom example deleted");
|
||||
}
|
||||
|
||||
function applyService(service: DockerService, port: number) {
|
||||
draft.service.name = service.name;
|
||||
draft.service.folder = service.folder;
|
||||
draft.service.port = port;
|
||||
draft.service.host = "127.0.0.1";
|
||||
}
|
||||
|
||||
function applyExample(example: AddonExample) {
|
||||
selectedExampleId.value = example.id;
|
||||
draft.code = example.code;
|
||||
notice.value = editorOpen.value
|
||||
? `Loaded example: ${example.name}`
|
||||
: `Loaded example: ${example.name}. Open the editor to review or modify the code.`;
|
||||
}
|
||||
|
||||
function selectExample(exampleId: string) {
|
||||
selectedExampleId.value = exampleId;
|
||||
}
|
||||
|
||||
function setOption(option: MitmOption) {
|
||||
selectedOptionName.value = option.name;
|
||||
if (!(option.name in draft.mitm_options)) {
|
||||
draft.mitm_options[option.name] = defaultFor(option);
|
||||
}
|
||||
}
|
||||
|
||||
function selectOption(optionName: string) {
|
||||
selectedOptionName.value = optionName;
|
||||
}
|
||||
|
||||
function removeOption(name: string) {
|
||||
delete draft.mitm_options[name];
|
||||
}
|
||||
|
||||
function updateOption(name: string, value: string, option?: MitmOption) {
|
||||
if (option?.type === "bool") {
|
||||
draft.mitm_options[name] = value === "true";
|
||||
return;
|
||||
}
|
||||
if (option?.type === "int" || option?.type === "optional int") {
|
||||
draft.mitm_options[name] = value === "" ? null : Number(value);
|
||||
return;
|
||||
}
|
||||
if (option?.type === "sequence of str") {
|
||||
draft.mitm_options[name] = value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
return;
|
||||
}
|
||||
draft.mitm_options[name] = value;
|
||||
}
|
||||
|
||||
function toggleEditor(force?: boolean) {
|
||||
editorOpen.value = typeof force === "boolean" ? force : !editorOpen.value;
|
||||
}
|
||||
|
||||
function setToolView(view: ToolView) {
|
||||
toolView.value = view;
|
||||
}
|
||||
|
||||
function cleanDraft(): ProxyDraft {
|
||||
return JSON.parse(
|
||||
JSON.stringify({
|
||||
proxy_port: draft.proxy_port,
|
||||
name: draft.name,
|
||||
code: draft.code,
|
||||
mode: draft.mode,
|
||||
mode_spec: draft.mode_spec || null,
|
||||
protocol: draft.protocol,
|
||||
listen_host: draft.listen_host,
|
||||
mitm_options: draft.mitm_options,
|
||||
healthcheck: {
|
||||
...draft.healthcheck,
|
||||
expected_status: draft.healthcheck.expected_status || null,
|
||||
protocol: draft.healthcheck.protocol || null,
|
||||
},
|
||||
service: {
|
||||
host: draft.service.host,
|
||||
port: draft.service.port,
|
||||
name: draft.service.name || null,
|
||||
folder: draft.service.folder || null,
|
||||
upstream_port: draft.service.upstream_port || null,
|
||||
},
|
||||
}),
|
||||
) as ProxyDraft;
|
||||
}
|
||||
|
||||
function replaceProxy(proxy: ProxyDocument) {
|
||||
const index = proxies.value.findIndex(
|
||||
(item) => item.proxy_port === proxy.proxy_port,
|
||||
);
|
||||
if (index === -1) proxies.value.push(proxy);
|
||||
else proxies.value[index] = proxy;
|
||||
}
|
||||
|
||||
function nextPort() {
|
||||
const used = new Set(proxies.value.map((proxy) => proxy.proxy_port));
|
||||
let port = 8080;
|
||||
while (used.has(port)) port += 1;
|
||||
return port;
|
||||
}
|
||||
|
||||
function defaultFor(option: MitmOption) {
|
||||
if (option.default !== null && option.default !== undefined) return option.default;
|
||||
if (option.type === "bool") return false;
|
||||
if (option.type === "int" || option.type === "optional int") return 0;
|
||||
if (option.type === "sequence of str") return [];
|
||||
return "";
|
||||
}
|
||||
|
||||
async function run(task: () => Promise<void>, success?: string) {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
notice.value = "";
|
||||
try {
|
||||
await task();
|
||||
if (success) notice.value = success;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return proxyRefs({
|
||||
activeOptions,
|
||||
applyExample,
|
||||
applyService,
|
||||
checkHealth,
|
||||
codeLineCount,
|
||||
customExampleName,
|
||||
draft,
|
||||
editorOpen,
|
||||
error,
|
||||
exampleSearch,
|
||||
filteredExamples,
|
||||
filteredOptions,
|
||||
health,
|
||||
loadLogs,
|
||||
loading,
|
||||
logs,
|
||||
newProxy,
|
||||
notice,
|
||||
optionCount,
|
||||
optionSearch,
|
||||
password,
|
||||
proxies,
|
||||
refreshAll,
|
||||
removeExample,
|
||||
removeOption,
|
||||
removeProxy,
|
||||
routeState,
|
||||
runtimeAction,
|
||||
saveCurrentAsExample,
|
||||
saveProxy,
|
||||
selectExample,
|
||||
selectedExample,
|
||||
selectedOption,
|
||||
selectedPort,
|
||||
selectedProxy,
|
||||
selectOption,
|
||||
services,
|
||||
setOption,
|
||||
setToolView,
|
||||
signIn,
|
||||
signOut,
|
||||
toggleEditor,
|
||||
token,
|
||||
toolView,
|
||||
updateOption,
|
||||
username,
|
||||
});
|
||||
}
|
||||
|
||||
export type ControlPlane = ReturnType<typeof useControlPlane>;
|
||||
|
||||
export const controlPlaneKey: InjectionKey<ControlPlane> = Symbol("control-plane");
|
||||
|
||||
export function useControlPlaneContext() {
|
||||
const context = inject(controlPlaneKey);
|
||||
if (!context) {
|
||||
throw new Error("Control plane context is not available");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
LSPClient,
|
||||
languageServerExtensions,
|
||||
type Transport,
|
||||
} from "@codemirror/lsp-client";
|
||||
|
||||
import { buildSocketUrl } from "../api";
|
||||
import type { PythonEditorSession } from "../types";
|
||||
|
||||
export type PythonLspConnection = {
|
||||
client: LSPClient;
|
||||
socket: WebSocket;
|
||||
};
|
||||
|
||||
export function connectPythonLsp(
|
||||
session: PythonEditorSession,
|
||||
): Promise<PythonLspConnection> {
|
||||
const url = buildSocketUrl("/proxy/editor/python/ws");
|
||||
const socket = new WebSocket(url);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let handlers: Array<(value: string) => void> = [];
|
||||
let settled = false;
|
||||
|
||||
socket.addEventListener("message", (event) => {
|
||||
const value =
|
||||
typeof event.data === "string" ? event.data : String(event.data ?? "");
|
||||
for (const handler of handlers) handler(value);
|
||||
});
|
||||
|
||||
socket.addEventListener("open", () => {
|
||||
const transport: Transport = {
|
||||
send(message) {
|
||||
socket.send(message);
|
||||
},
|
||||
subscribe(handler) {
|
||||
handlers.push(handler);
|
||||
},
|
||||
unsubscribe(handler) {
|
||||
handlers = handlers.filter((value) => value !== handler);
|
||||
},
|
||||
};
|
||||
|
||||
const client = new LSPClient({
|
||||
rootUri: session.root_uri,
|
||||
extensions: languageServerExtensions(),
|
||||
}).connect(transport);
|
||||
|
||||
settled = true;
|
||||
resolve({ client, socket });
|
||||
});
|
||||
|
||||
socket.addEventListener("error", () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(new Error("Unable to connect to the Python language server"));
|
||||
});
|
||||
|
||||
socket.addEventListener("close", (event) => {
|
||||
if (settled || socket.readyState !== WebSocket.CLOSED) return;
|
||||
settled = true;
|
||||
reject(new Error(event.reason || "Python language server connection closed"));
|
||||
});
|
||||
});
|
||||
}
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*?worker" {
|
||||
const workerConstructor: {
|
||||
new (): Worker;
|
||||
};
|
||||
export default workerConstructor;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createApp } from "vue";
|
||||
|
||||
import App from "./App.vue";
|
||||
import "./styles.css";
|
||||
|
||||
createApp(App).mount("#app");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
||||
export type ServiceEndpoint = {
|
||||
host: string;
|
||||
port: number;
|
||||
name?: string | null;
|
||||
folder?: string | null;
|
||||
upstream_port?: number | null;
|
||||
};
|
||||
|
||||
export type HealthCheck = {
|
||||
enabled: boolean;
|
||||
protocol?: string | null;
|
||||
path: string;
|
||||
method: string;
|
||||
expected_status?: number | null;
|
||||
udp_payload: string;
|
||||
expect_response: boolean;
|
||||
verify_tls: boolean;
|
||||
timeout_seconds: number;
|
||||
};
|
||||
|
||||
export type ProxyDocument = {
|
||||
proxy_port: number;
|
||||
name: string;
|
||||
code: string;
|
||||
mode: string;
|
||||
mode_spec?: string | null;
|
||||
protocol: string;
|
||||
listen_host: string;
|
||||
mitm_options: Record<string, unknown>;
|
||||
healthcheck: HealthCheck;
|
||||
status: string;
|
||||
pid?: number | null;
|
||||
script_path?: string | null;
|
||||
config_path?: string | null;
|
||||
log_path?: string | null;
|
||||
last_error?: string | null;
|
||||
service: ServiceEndpoint;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
export type DockerService = {
|
||||
name: string;
|
||||
ports: number[];
|
||||
folder: string;
|
||||
};
|
||||
|
||||
export type MitmOption = {
|
||||
name: string;
|
||||
type: string;
|
||||
default: unknown;
|
||||
description: string;
|
||||
choices: string[];
|
||||
reserved: boolean;
|
||||
docs_url?: string | null;
|
||||
};
|
||||
|
||||
export type AddonExample = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
code: string;
|
||||
source_url?: string | null;
|
||||
built_in: boolean;
|
||||
};
|
||||
|
||||
export type HealthResult = {
|
||||
enabled: boolean;
|
||||
overall: string;
|
||||
checks: Array<{
|
||||
target: string;
|
||||
host: string;
|
||||
port: number;
|
||||
protocol: string;
|
||||
healthy: boolean;
|
||||
error?: string | null;
|
||||
details: Record<string, unknown>;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type PythonEditorSession = {
|
||||
root_uri: string;
|
||||
file_uri: string;
|
||||
};
|
||||
|
||||
export type ProxyDraft = Omit<
|
||||
ProxyDocument,
|
||||
"status" | "pid" | "script_path" | "config_path" | "log_path" | "last_error"
|
||||
>;
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
build: {
|
||||
chunkSizeWarningLimit: 3000,
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://127.0.0.1:3671",
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, ""),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user