BIG BIG BIG BIG BIG UPDATES

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
gabdevele
2026-05-04 23:59:48 +02:00
co-authored by Copilot
Unverified
parent 46f7cd6a5f
commit 93ba8e9ac2
88 changed files with 7591 additions and 447 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.vite
npm-debug.log
+16
View File
@@ -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
+12
View File
@@ -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>
+22
View File
@@ -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;
}
}
+1670
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -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"
}
}
+49
View File
@@ -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>
+109
View File
@@ -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`),
};
+34
View File
@@ -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>
+7
View File
@@ -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>
+157
View File
@@ -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>
+27
View File
@@ -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>
+79
View File
@@ -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>
+111
View File
@@ -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>
+35
View File
@@ -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>
+19
View File
@@ -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>
+142
View File
@@ -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>
+52
View File
@@ -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>
+156
View File
@@ -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>
+54
View File
@@ -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>
+68
View File
@@ -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>
+9
View File
@@ -0,0 +1,9 @@
<script setup lang="ts">
defineProps<{
status: string;
}>();
</script>
<template>
<span class="status-pill" :class="status">{{ status }}</span>
</template>
+571
View File
@@ -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;
}
+65
View File
@@ -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"));
});
});
}
+8
View File
@@ -0,0 +1,8 @@
/// <reference types="vite/client" />
declare module "*?worker" {
const workerConstructor: {
new (): Worker;
};
export default workerConstructor;
}
+6
View File
@@ -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
+90
View File
@@ -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"
>;
+19
View File
@@ -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"]
}
+20
View File
@@ -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/, ""),
},
},
},
});