feat: enhance service discovery and proxy management

This commit is contained in:
gabdevele
2026-05-05 13:57:46 +02:00 Unverified
parent 93ba8e9ac2
commit a523c2faa3
53 changed files with 3206 additions and 706 deletions
+23 -1
View File
@@ -17,7 +17,8 @@
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.41.1",
"codemirror": "^6.0.2",
"vue": "^3.5.26"
"vue": "^3.5.26",
"vue-router": "^4.6.3"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
@@ -1131,6 +1132,12 @@
"@vue/shared": "3.5.33"
}
},
"node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/@vue/language-core": {
"version": "3.2.8",
"resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.8.tgz",
@@ -1643,6 +1650,21 @@
}
}
},
"node_modules/vue-router": {
"version": "4.6.4",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/vue-tsc": {
"version": "3.2.8",
"resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.8.tgz",
+2 -1
View File
@@ -19,7 +19,8 @@
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.41.1",
"codemirror": "^6.0.2",
"vue": "^3.5.26"
"vue": "^3.5.26",
"vue-router": "^4.6.3"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
+5 -2
View File
@@ -2,6 +2,7 @@
import { onMounted, provide } from "vue";
import AppHeader from "./components/AppHeader.vue";
import AppToast from "./components/AppToast.vue";
import LoginView from "./components/LoginView.vue";
import ProxyList from "./components/ProxyList.vue";
import ProxyWorkbench from "./components/ProxyWorkbench.vue";
@@ -24,15 +25,17 @@ onMounted(() => {
v-model:username="plane.username"
v-model:password="plane.password"
:loading="plane.loading"
:error="plane.error"
:error="plane.authMessage || plane.error"
@submit="plane.signIn"
/>
<main v-else class="app-shell">
<AppToast v-if="plane.notice" :message="plane.notice" />
<AppHeader
:loading="plane.loading"
@refresh="plane.refreshAll"
@create="plane.newProxy"
@create="plane.openCreateWizard()"
@logout="plane.signOut"
/>
+18 -3
View File
@@ -1,16 +1,26 @@
import type {
AddonExample,
DockerService,
HealthResult,
MitmOption,
PythonEditorSession,
ProxyDocument,
ProxyDraft,
ServiceDiscoveryResponse,
} from "./types";
const TOKEN_KEY = "theplug.token";
export const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api";
export class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.name = "ApiError";
this.status = status;
}
}
export function getToken() {
return localStorage.getItem(TOKEN_KEY);
}
@@ -52,7 +62,9 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<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));
const message =
typeof detail === "string" ? detail : JSON.stringify(detail);
throw new ApiError(response.status, message);
}
return body as T;
}
@@ -72,7 +84,10 @@ export async function logout() {
export const api = {
proxies: () => request<ProxyDocument[]>("/proxy"),
services: () => request<DockerService[]>("/proxy/services"),
services: (scanRoot?: string) =>
request<ServiceDiscoveryResponse>(
`/proxy/services${scanRoot ? `?scan_root=${encodeURIComponent(scanRoot)}` : ""}`,
),
options: () => request<MitmOption[]>("/proxy/mitmproxy/options"),
examples: () => request<AddonExample[]>("/proxy/mitmproxy/examples"),
editorSession: () =>
+14 -5
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import AppLogo from "./AppLogo.vue";
import UiIcon from "./UiIcon.vue";
defineProps<{
loading: boolean;
@@ -22,13 +23,21 @@ const emit = defineEmits<{
</div>
</div>
<div class="header-meta">
<span class="team">mitmdump</span>
<span v-if="loading" class="muted">applying changes</span>
<span v-if="loading" class="muted">saving</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>
<button class="ghost button-icon" :disabled="loading" @click="emit('refresh')">
<UiIcon name="refresh" />
<span>Refresh</span>
</button>
<button class="primary button-icon" @click="emit('create')">
<UiIcon name="plus" />
<span>New</span>
</button>
<button class="ghost button-icon" @click="emit('logout')">
<UiIcon name="logout" />
<span>Logout</span>
</button>
</div>
</header>
</template>
+11
View File
@@ -0,0 +1,11 @@
<script setup lang="ts">
defineProps<{
message: string;
}>();
</script>
<template>
<div class="app-toast" role="status" aria-live="polite">
{{ message }}
</div>
</template>
+4 -15
View File
@@ -12,7 +12,6 @@ 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 =
@@ -110,15 +109,9 @@ async function mountEditor() {
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";
},
);
void languageClient.initializing.catch(() => {
error.value = "Python language tools are unavailable.";
});
editor = new EditorView({
doc: model.value,
@@ -141,17 +134,13 @@ async function mountEditor() {
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>
<p v-if="error" class="field-error editor-error">{{ error }}</p>
<div ref="host" class="code-editor" />
</div>
</template>
+1 -3
View File
@@ -11,13 +11,11 @@ const plane = useControlPlaneContext();
<section class="editor-pane panel">
<div class="panel-title">
<div>
<p class="eyebrow">Code</p>
<h2>Addon editor</h2>
<h2>Proxy script</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">
+8 -7
View File
@@ -7,23 +7,20 @@ const plane = useControlPlaneContext();
<template>
<section class="panel stack-panel tool-panel">
<div class="panel-title">
<div>
<p class="eyebrow">Templates</p>
<h2>Examples</h2>
</div>
<div><h2>Templates</h2></div>
</div>
<div class="inline-grid">
<input
v-model="plane.customExampleName"
placeholder="Save current code as..."
placeholder="Save current script as template..."
/>
<button @click="plane.saveCurrentAsExample">Save</button>
</div>
<div class="example-shell">
<div class="example-browser">
<input v-model="plane.exampleSearch" placeholder="Search examples" />
<input v-model="plane.exampleSearch" placeholder="Search templates" />
<div class="example-index">
<button
@@ -35,6 +32,7 @@ const plane = useControlPlaneContext();
>
<strong>{{ example.name }}</strong>
<small>{{ example.category }}</small>
<span class="example-snippet">{{ example.description }}</span>
</button>
</div>
</div>
@@ -42,7 +40,6 @@ const plane = useControlPlaneContext();
<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">
@@ -55,6 +52,10 @@ const plane = useControlPlaneContext();
<p class="doc-copy">{{ plane.selectedExample.description }}</p>
<pre class="terminal template-preview-terminal">{{
plane.selectedExample.code
}}</pre>
<div class="doc-actions">
<button @click="plane.applyExample(plane.selectedExample)">Use template</button>
<a
+73 -70
View File
@@ -1,22 +1,29 @@
<script setup lang="ts">
import { protocols, useControlPlaneContext } from "../composables/useControlPlane";
import UiIcon from "./UiIcon.vue";
const props = withDefaults(defineProps<{ embedded?: boolean }>(), {
embedded: false,
});
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
<section :class="props.embedded ? 'wizard-step-shell' : 'panel stack-panel compact-panel'">
<div v-if="!props.embedded" class="panel-title">
<div><h2>Checks</h2></div>
<button
class="button-icon"
:disabled="!plane.selectedProxy"
@click="plane.checkHealth"
>
<UiIcon name="refresh" />
<span>Run</span>
</button>
</div>
<div class="metric-grid">
<div v-if="!props.embedded" class="metric-grid">
<article class="metric-card">
<span class="metric-label">State</span>
<strong>{{ plane.draft.healthcheck.enabled ? "Enabled" : "Disabled" }}</strong>
@@ -35,67 +42,63 @@ const plane = useControlPlaneContext();
</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 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>
<div v-if="plane.health" class="health-box" :class="plane.health.overall">
<strong>{{ plane.health.overall }}</strong>
+4 -4
View File
@@ -20,16 +20,16 @@ const emit = defineEmits<{
<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>
<h1>Sign in</h1>
<p class="muted">Manage proxy routes, runtime state, and mitmdump scripts.</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>
<button class="primary" :disabled="loading">Sign in</button>
</form>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="error" class="error auth-alert">{{ error }}</p>
</section>
</main>
</template>
+16 -5
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { useControlPlaneContext } from "../composables/useControlPlane";
import UiIcon from "./UiIcon.vue";
const plane = useControlPlaneContext();
</script>
@@ -7,13 +8,23 @@ const plane = useControlPlaneContext();
<template>
<section class="panel stack-panel tool-panel logs-panel">
<div class="panel-title">
<div>
<p class="eyebrow">Output</p>
<h2>Logs</h2>
<div><h2>Logs</h2></div>
<div class="doc-actions">
<button class="ghost button-icon" :disabled="!plane.selectedProxy" @click="plane.toggleLogsPaused">
<UiIcon :name="plane.logsPaused ? 'resume' : 'pause'" />
<span>{{ plane.logsPaused ? "Resume" : "Pause" }}</span>
</button>
<button class="ghost button-icon" @click="plane.clearLogs">
<UiIcon name="clear" />
<span>Clear</span>
</button>
</div>
<button :disabled="!plane.selectedProxy" @click="plane.loadLogs">Load</button>
</div>
<pre class="terminal">{{ plane.logs || "No logs loaded." }}</pre>
<input v-model="plane.logFilter" placeholder="Filter logs" />
<pre class="terminal">{{
plane.filteredLogs || "Logs will appear here automatically."
}}</pre>
</section>
</template>
+277 -86
View File
@@ -1,6 +1,11 @@
<script setup lang="ts">
import type { MitmOption } from "../types";
import { useControlPlaneContext } from "../composables/useControlPlane";
import UiIcon from "./UiIcon.vue";
const props = withDefaults(defineProps<{ embedded?: boolean }>(), {
embedded: false,
});
const plane = useControlPlaneContext();
@@ -27,65 +32,57 @@ function formatDefault(option?: MitmOption | null) {
</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 }}
<section :class="props.embedded ? 'wizard-step-shell' : 'panel stack-panel tool-panel'">
<div v-if="!props.embedded" class="panel-title">
<h2>Options</h2>
<div class="title-actions">
<span class="counter">{{ plane.activeOptionCount }}</span>
<div class="segmented">
<button
:class="{ active: plane.optionScope === 'focused' }"
@click="plane.toggleOptionScope('focused')"
>
Common
</button>
<button
:class="{ active: plane.optionScope === 'all' }"
@click="plane.toggleOptionScope('all')"
>
All
</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 v-if="props.embedded" class="option-step-layout">
<aside class="option-browser option-browser-embedded">
<div class="option-toolbar">
<input v-model="plane.optionSearch" placeholder="Search options" />
<div class="segmented">
<button
:class="{ active: plane.optionScope === 'focused' }"
@click="plane.toggleOptionScope('focused')"
>
Common
</button>
<button
:class="{ active: plane.optionScope === 'all' }"
@click="plane.toggleOptionScope('all')"
>
All
</button>
</div>
</div>
<div class="option-browser-meta">
<span class="option-count">{{ plane.filteredOptions.length }}</span>
<p class="muted small">
{{
plane.optionScope === "focused"
? "Common options for the usual proxy setup."
: `All ${plane.optionCount} public mitmdump options.`
}}
</p>
</div>
<div class="option-table">
<button
@@ -99,44 +96,238 @@ function formatDefault(option?: MitmOption | null) {
<small>{{ option.type }}</small>
</button>
</div>
</aside>
<div class="option-step-main">
<article v-if="plane.selectedOption" class="option-doc option-doc-embedded">
<div class="option-doc-head">
<div>
<h3>{{ plane.selectedOption.name }}</h3>
<p class="doc-copy">{{ plane.selectedOption.description }}</p>
</div>
<span class="type-chip">{{ plane.selectedOption.type }}</span>
</div>
<dl class="doc-facts doc-facts-compact">
<div class="doc-fact">
<dt>Default</dt>
<dd>{{ formatDefault(plane.selectedOption) }}</dd>
</div>
<div
v-if="plane.selectedOption.choices.length"
class="doc-fact"
>
<dt>Choices</dt>
<dd>{{ plane.selectedOption.choices.join(", ") }}</dd>
</div>
<div v-if="plane.selectedOption.reserved" class="doc-fact">
<dt>State</dt>
<dd>Managed by thePlug</dd>
</div>
</dl>
<div class="doc-actions">
<button
class="button-icon"
:disabled="plane.selectedOption.reserved"
@click="plane.setOption(plane.selectedOption)"
>
<UiIcon name="plus" />
<span>Add option</span>
</button>
<a
class="button-link"
v-if="plane.selectedOption.docs_url"
:href="plane.selectedOption.docs_url"
target="_blank"
rel="noreferrer"
>
<UiIcon name="link" />
Official docs
</a>
</div>
</article>
<section class="option-active-card">
<div class="option-active-head">
<h3>Configured options</h3>
<span class="counter">{{ plane.activeOptionCount }}</span>
</div>
<div v-if="plane.activeOptions.length" class="active-options compact-table">
<div class="table-head">
<span>Option</span>
<span>Value</span>
<span />
</div>
<div class="table-body compact-scroll">
<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 table-action" @click="plane.removeOption(item.name)">
Remove
</button>
</div>
</div>
</div>
<p v-else class="muted small">No custom options configured yet.</p>
</section>
</div>
</div>
<template v-else>
<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 compact-scroll">
<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 table-action" @click="plane.removeOption(item.name)">
Remove
</button>
</div>
</div>
<p v-else class="muted small">No active options.</p>
</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 class="option-shell">
<div class="option-browser">
<div class="option-search-row">
<input v-model="plane.optionSearch" placeholder="Search options" />
<span class="option-count">{{ plane.filteredOptions.length }}</span>
</div>
<p class="muted small compact-copy">
{{
plane.optionScope === "focused"
? "Showing the options that are usually useful in this UI."
: `Showing all ${plane.optionCount} public mitmdump options.`
}}
</p>
<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>
<span class="type-chip">{{ plane.selectedOption.type }}</span>
</div>
<p class="doc-copy">{{ plane.selectedOption.description }}</p>
<article v-if="plane.selectedOption" class="option-doc">
<div class="option-doc-head">
<div>
<h3>{{ plane.selectedOption.name }}</h3>
</div>
<span class="type-chip">{{ plane.selectedOption.type }}</span>
</div>
<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>
<p class="doc-copy">{{ plane.selectedOption.description }}</p>
<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>
<dl class="doc-facts">
<div class="doc-fact">
<dt>Default</dt>
<dd>{{ formatDefault(plane.selectedOption) }}</dd>
</div>
<div
v-if="plane.selectedOption.choices.length"
class="doc-fact"
>
<dt>Choices</dt>
<dd>{{ plane.selectedOption.choices.join(", ") }}</dd>
</div>
<div v-if="plane.selectedOption.reserved" class="doc-fact">
<dt>State</dt>
<dd>Managed by thePlug</dd>
</div>
</dl>
<div class="doc-actions">
<button
class="button-icon"
:disabled="plane.selectedOption.reserved"
@click="plane.setOption(plane.selectedOption)"
>
<UiIcon name="plus" />
<span>Add option</span>
</button>
<a
class="button-link"
v-if="plane.selectedOption.docs_url"
:href="plane.selectedOption.docs_url"
target="_blank"
rel="noreferrer"
>
<UiIcon name="link" />
Official docs
</a>
</div>
</article>
</div>
</template>
</section>
</template>
+2 -3
View File
@@ -16,15 +16,14 @@ const emit = defineEmits<{
<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>
<strong>No proxies yet</strong>
<span>Create one to start.</span>
</div>
<div class="proxy-list">
@@ -0,0 +1,73 @@
<script setup lang="ts">
import HealthPanel from "./HealthPanel.vue";
import OptionsPanel from "./OptionsPanel.vue";
import RuntimePanel from "./RuntimePanel.vue";
import UiIcon from "./UiIcon.vue";
import { useControlPlaneContext } from "../composables/useControlPlane";
const plane = useControlPlaneContext();
</script>
<template>
<div v-if="plane.wizardOpen" class="modal-backdrop" @click.self="plane.closeWizard">
<section class="modal-shell">
<header class="modal-head">
<div>
<h2>{{ plane.wizardTitle }}</h2>
<p class="modal-step-copy">
Step {{ plane.wizardStep + 1 }} of 3: {{ plane.wizardStepTitle }}
</p>
</div>
<div class="modal-head-actions">
<button class="primary button-icon" @click="plane.saveProxy">
<UiIcon name="save" />
<span>Save & close</span>
</button>
<button class="ghost button-icon" @click="plane.closeWizard">
<UiIcon name="close" />
<span>Close</span>
</button>
</div>
</header>
<div class="modal-stepper">
<button
v-for="(step, index) in ['Proxy route', 'Healthchecks', 'Custom mitmdump options']"
:key="step"
class="modal-step-chip"
:class="{ active: plane.wizardStep === index, done: plane.wizardStep > index }"
@click="plane.wizardStep = index"
>
<span>{{ index + 1 }}</span>
<strong>{{ step }}</strong>
</button>
</div>
<div class="modal-body">
<RuntimePanel v-if="plane.wizardStep === 0" embedded />
<HealthPanel v-else-if="plane.wizardStep === 1" embedded />
<OptionsPanel v-else embedded />
</div>
<footer class="modal-actions">
<button
v-if="plane.canGoBackWizard"
class="ghost button-icon"
@click="plane.previousWizardStep"
>
<UiIcon name="back" />
<span>Back</span>
</button>
<div class="modal-action-spacer" />
<button
v-if="plane.canGoNextWizard"
class="primary button-icon"
@click="plane.nextWizardStep"
>
<UiIcon name="next" />
<span>Next</span>
</button>
</footer>
</section>
</div>
</template>
+52 -102
View File
@@ -2,12 +2,11 @@
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 ProxyWizardModal from "./ProxyWizardModal.vue";
import ServiceDiscoveryPanel from "./ServiceDiscoveryPanel.vue";
import StatusPill from "./StatusPill.vue";
import UiIcon from "./UiIcon.vue";
const plane = useControlPlaneContext();
</script>
@@ -16,141 +15,92 @@ const plane = useControlPlaneContext();
<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">
<h1>{{ plane.selectedProxy ? plane.draft.name : "Proxy workspace" }}</h1>
<div v-if="plane.selectedProxy" class="route-row route-summary">
<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-else class="summary-copy">
Create a proxy from autodiscovery or open one from the inventory.
</p>
<p v-if="plane.selectedProxy && plane.routeState.summaryHint" class="summary-copy">
{{ plane.routeState.summaryHint }}
</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
v-if="plane.selectedProxy"
class="ghost button-icon"
@click="plane.openEditWizard()"
:disabled="plane.loading"
>
<UiIcon name="settings" />
<span>Settings</span>
</button>
<button
v-if="plane.actionState.showStart"
class="success button-icon"
@click="plane.runtimeAction('start')"
:disabled="!plane.selectedProxy || plane.loading"
:disabled="!plane.actionState.canStart"
>
Start
<UiIcon name="play" />
<span>Start</span>
</button>
<button
v-if="plane.actionState.showStop"
class="danger button-icon"
@click="plane.runtimeAction('stop')"
:disabled="!plane.selectedProxy || plane.loading"
:disabled="!plane.actionState.canStop"
>
Stop
<UiIcon name="stop" />
<span>Stop</span>
</button>
<button
v-if="plane.actionState.showRestart"
class="warning button-icon"
@click="plane.runtimeAction('restart')"
:disabled="!plane.selectedProxy || plane.loading"
:disabled="!plane.actionState.canRestart"
>
Restart
</button>
<button class="ghost" @click="plane.toggleEditor()">
{{ plane.editorOpen ? "Hide editor" : "Open editor" }}
<UiIcon name="restart" />
<span>Restart</span>
</button>
<button
class="danger"
v-if="plane.selectedProxy"
class="danger button-icon"
@click="plane.removeProxy"
:disabled="!plane.selectedProxy || plane.loading"
:disabled="!plane.actionState.canDelete"
>
Delete
<UiIcon name="trash" />
<span>Delete</span>
</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>
<ServiceDiscoveryPanel v-if="!plane.selectedProxy && !plane.wizardOpen" />
<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 v-if="plane.selectedProxy" class="workspace-main-row">
<div class="workspace-editor-pane">
<EditorPane />
</div>
<div class="workspace-logs-pane">
<LogsPanel />
</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" />
<div v-if="plane.selectedProxy" class="workspace-templates-row">
<ExamplesPanel />
</div>
<ProxyWizardModal />
</section>
</template>
+65 -18
View File
@@ -1,26 +1,69 @@
<script setup lang="ts">
import { modes, protocols, useControlPlaneContext } from "../composables/useControlPlane";
import UiIcon from "./UiIcon.vue";
const props = withDefaults(defineProps<{ embedded?: boolean }>(), {
embedded: false,
});
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>
<section :class="props.embedded ? 'wizard-step-shell' : 'panel stack-panel compact-panel'">
<div v-if="!props.embedded" class="panel-title">
<h2>Proxy route</h2>
</div>
<div class="form-grid tight-grid">
<div class="route-strip">
<span class="route-chip">{{ plane.routeState.configuredListen }}</span>
<span class="route-arrow">to</span>
<span class="route-chip">{{ plane.routeState.configuredTarget }}</span>
<span class="route-arrow">live</span>
<span class="route-chip">{{ plane.routeState.liveTarget }}</span>
</div>
<div class="route-overview-grid">
<article class="metric-card">
<span class="metric-label">Service</span>
<strong>{{ plane.routeState.serviceLabel }}</strong>
</article>
<article class="metric-card">
<span class="metric-label">Mode</span>
<strong>{{ plane.routeState.modeLabel }}</strong>
</article>
</div>
<div class="form-grid route-form-grid tight-grid single-row-grid">
<label>
Name
<input v-model="plane.draft.name" />
</label>
</div>
<div class="form-grid tight-grid">
<label>
Proxy port
<input v-model.number="plane.draft.proxy_port" type="number" />
<input
v-model.number="plane.draft.proxy_port"
:disabled="Boolean(plane.selectedProxy)"
type="number"
/>
<small v-if="plane.selectedProxy">
Port is fixed for existing proxies. Create a new proxy to change it.
</small>
</label>
<label>
Upstream host
<input v-model="plane.draft.service.host" />
</label>
<label>
Upstream port
<input v-model.number="plane.draft.service.port" type="number" />
</label>
<label>
Listen host
<input v-model="plane.draft.listen_host" />
</label>
<label>
Mode
@@ -34,21 +77,25 @@ const plane = useControlPlaneContext();
<option v-for="protocol in protocols" :key="protocol">{{ protocol }}</option>
</select>
</label>
<label>
Listen host
<input v-model="plane.draft.listen_host" />
</label>
<label>
<label class="route-mode-spec">
Mode spec
<input
v-model="plane.draft.mode_spec"
placeholder="optional raw mitmproxy mode"
placeholder="optional raw mitmdump mode"
/>
</label>
<div
v-if="plane.routeState.takeoverMismatch"
class="inline-grid advanced-inline-action"
>
<p class="muted small compact-copy">
Proxy port differs from the original service port.
</p>
<button class="ghost button-icon" @click="plane.matchProxyPortToService">
<UiIcon name="link" />
<span>Use service port</span>
</button>
</div>
</div>
<p class="inline-note">
Save stores this configuration. Start launches mitmdump with the selected mode.
</p>
</section>
</template>
@@ -0,0 +1,80 @@
<script setup lang="ts">
import { useControlPlaneContext } from "../composables/useControlPlane";
import StatusPill from "./StatusPill.vue";
import UiIcon from "./UiIcon.vue";
const plane = useControlPlaneContext();
</script>
<template>
<section class="panel stack-panel compact-panel">
<div class="panel-title">
<h2>Services</h2>
<button class="ghost button-icon" :disabled="plane.servicesLoading" @click="plane.refreshServices">
<UiIcon name="refresh" />
<span>{{ plane.servicesLoading ? "Scanning" : "Refresh" }}</span>
</button>
</div>
<div class="inline-grid">
<label>
Scan root
<input v-model="plane.scanRoot" placeholder="/root" />
</label>
<button class="primary button-icon" :disabled="plane.servicesLoading" @click="plane.refreshServices">
<UiIcon name="refresh" />
<span>Scan</span>
</button>
</div>
<p v-if="plane.servicesLoading" class="muted small compact-copy">
Scanning compose folders in {{ plane.scanRoot }}...
</p>
<p v-else-if="!plane.serviceEntries.length" class="muted small compact-copy">
No compose services detected in the scan root.
</p>
<div v-else class="service-discovery-list">
<article
v-for="entry in plane.serviceEntries"
:key="entry.id"
class="service-discovery-row"
>
<div class="service-discovery-main">
<strong>{{ entry.service.name }}</strong>
<span class="service-route-copy">
{{ entry.listenHost }}:{{ entry.binding.published }}
</span>
<small class="service-folder">{{ entry.service.folder }}</small>
</div>
<div class="service-discovery-actions">
<template v-if="entry.proxy">
<StatusPill :status="entry.proxy.status" />
<button class="ghost button-icon" @click="plane.selectedPort = entry.proxy.proxy_port">
<UiIcon name="link" />
<span>Open proxy</span>
</button>
<button
class="ghost button-icon"
@click="plane.openEditWizard(entry.proxy)"
>
<UiIcon name="settings" />
<span>Edit</span>
</button>
</template>
<button
v-else
class="primary button-icon"
:disabled="plane.loading"
@click="plane.openCreateWizard(entry.service, entry.binding)"
>
<UiIcon name="plus" />
<span>Create proxy</span>
</button>
</div>
</article>
</div>
</section>
</template>
-68
View File
@@ -1,68 +0,0 @@
<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>
+184
View File
@@ -0,0 +1,184 @@
<script setup lang="ts">
defineProps<{
name:
| "save"
| "play"
| "stop"
| "restart"
| "trash"
| "plus"
| "refresh"
| "logout"
| "terminal"
| "link"
| "spark"
| "close"
| "back"
| "next"
| "settings"
| "pause"
| "resume"
| "clear";
}>();
</script>
<template>
<svg
v-if="name === 'save'"
viewBox="0 0 16 16"
aria-hidden="true"
class="ui-icon"
>
<path d="M3 2.5h8l2.5 2.5v8.5H2.5v-11z" />
<path d="M5 2.5v4h5v-4" />
<path d="M5 11h6" />
</svg>
<svg
v-else-if="name === 'play'"
viewBox="0 0 16 16"
aria-hidden="true"
class="ui-icon"
>
<path d="M5 3.5l7 4.5-7 4.5z" />
</svg>
<svg
v-else-if="name === 'stop'"
viewBox="0 0 16 16"
aria-hidden="true"
class="ui-icon"
>
<rect x="4" y="4" width="8" height="8" />
</svg>
<svg
v-else-if="name === 'restart'"
viewBox="0 0 16 16"
aria-hidden="true"
class="ui-icon"
>
<path d="M12 5V2.5L14 4.5 12 6.5V5h-1.5A4.5 4.5 0 106.6 12" />
</svg>
<svg
v-else-if="name === 'trash'"
viewBox="0 0 16 16"
aria-hidden="true"
class="ui-icon"
>
<path d="M3.5 4.5h9" />
<path d="M6 4.5v-2h4v2" />
<path d="M5 4.5l.5 8h5l.5-8" />
</svg>
<svg
v-else-if="name === 'plus'"
viewBox="0 0 16 16"
aria-hidden="true"
class="ui-icon"
>
<path d="M8 3v10" />
<path d="M3 8h10" />
</svg>
<svg
v-else-if="name === 'refresh'"
viewBox="0 0 16 16"
aria-hidden="true"
class="ui-icon"
>
<path d="M12 4.5V2l2 2-2 2V4.5H7.5a4 4 0 103.8 5.2" />
</svg>
<svg
v-else-if="name === 'logout'"
viewBox="0 0 16 16"
aria-hidden="true"
class="ui-icon"
>
<path d="M7 3H3.5v9H7" />
<path d="M9 5.5L12 8l-3 2.5" />
<path d="M5 8h7" />
</svg>
<svg
v-else-if="name === 'terminal'"
viewBox="0 0 16 16"
aria-hidden="true"
class="ui-icon"
>
<path d="M3 4.5l3 3-3 3" />
<path d="M7.5 11h4.5" />
</svg>
<svg
v-else-if="name === 'link'"
viewBox="0 0 16 16"
aria-hidden="true"
class="ui-icon"
>
<path d="M6.5 9.5l3-3" />
<path d="M5 11H4a2.5 2.5 0 010-5h2" />
<path d="M11 5h1a2.5 2.5 0 010 5H10" />
</svg>
<svg
v-else-if="name === 'close'"
viewBox="0 0 16 16"
aria-hidden="true"
class="ui-icon"
>
<path d="M4 4l8 8" />
<path d="M12 4l-8 8" />
</svg>
<svg
v-else-if="name === 'back'"
viewBox="0 0 16 16"
aria-hidden="true"
class="ui-icon"
>
<path d="M10.5 3.5L5.5 8l5 4.5" />
</svg>
<svg
v-else-if="name === 'next'"
viewBox="0 0 16 16"
aria-hidden="true"
class="ui-icon"
>
<path d="M5.5 3.5L10.5 8l-5 4.5" />
</svg>
<svg
v-else-if="name === 'settings'"
viewBox="0 0 16 16"
aria-hidden="true"
class="ui-icon"
>
<path d="M3.5 4.5h9" />
<path d="M3.5 8h9" />
<path d="M3.5 11.5h9" />
<circle cx="6" cy="4.5" r="1.1" />
<circle cx="10" cy="8" r="1.1" />
<circle cx="7.5" cy="11.5" r="1.1" />
</svg>
<svg
v-else-if="name === 'pause'"
viewBox="0 0 16 16"
aria-hidden="true"
class="ui-icon"
>
<path d="M5.5 3.5v9" />
<path d="M10.5 3.5v9" />
</svg>
<svg
v-else-if="name === 'resume'"
viewBox="0 0 16 16"
aria-hidden="true"
class="ui-icon"
>
<path d="M5 3.5l7 4.5-7 4.5z" />
</svg>
<svg
v-else-if="name === 'clear'"
viewBox="0 0 16 16"
aria-hidden="true"
class="ui-icon"
>
<path d="M4 4l8 8" />
<path d="M12 4l-8 8" />
<path d="M3.5 12.5h9" />
</svg>
<svg v-else viewBox="0 0 16 16" aria-hidden="true" class="ui-icon">
<path d="M8 2.5l1.1 2.3 2.4.3-1.8 1.8.4 2.6L8 8.3 5.9 9.5l.4-2.6-1.8-1.8 2.4-.3z" />
</svg>
</template>
+581 -72
View File
@@ -7,19 +7,38 @@ import {
watch,
type InjectionKey,
} from "vue";
import { useRoute, useRouter } from "vue-router";
import { api, getToken, login, logout } from "../api";
import { ApiError, api, getToken, login, logout, setToken } from "../api";
import type {
AddonExample,
DockerService,
DockerServiceBinding,
HealthResult,
MitmOption,
ProxyDocument,
ProxyDraft,
} from "../types";
const editorStorageKey = "theplug.editor.open";
const toolStorageKey = "theplug.tool.view";
const optionScopeStorageKey = "theplug.option.scope";
const scanRootStorageKey = "theplug.scan.root";
const focusedMitmOptions = new Set([
"anticache",
"block_global",
"connection_strategy",
"flow_detail",
"http2",
"http3",
"keep_host_header",
"rawtcp",
"showhost",
"ssl_insecure",
"termlog_verbosity",
"upstream_cert",
"validate_inbound_headers",
"websocket",
]);
export const modes = [
"reverse",
@@ -46,7 +65,7 @@ export const protocols = [
];
export type RuntimeAction = "start" | "stop" | "restart";
export type ToolView = "options" | "examples" | "logs";
export type OptionScope = "focused" | "all";
export function createDefaultDraft(port = 8080, code?: string): ProxyDraft {
return {
@@ -82,6 +101,7 @@ def request(flow: http.HTTPFlow) -> None:
service: {
host: "127.0.0.1",
port: 9000,
bind_host: null,
name: null,
folder: null,
upstream_port: null,
@@ -99,13 +119,48 @@ function writeSession(key: string, value: string) {
window.sessionStorage.setItem(key, value);
}
function parseProxyPort(value: unknown): number | null {
const port = Number(value);
return Number.isInteger(port) && port > 0 ? port : null;
}
function parseWizardMode(value: unknown): "create" | "edit" | null {
return value === "create" || value === "edit" ? value : null;
}
function parseWizardStep(value: unknown): number {
const step = Number(value);
return step >= 0 && step <= 2 ? step : 0;
}
function sameQuery(
current: Record<string, unknown>,
expected: Record<string, string | undefined>,
) {
const keys = new Set([...Object.keys(current), ...Object.keys(expected)]);
for (const key of keys) {
const currentValue = current[key];
const normalizedCurrent = Array.isArray(currentValue)
? String(currentValue[0] ?? "")
: typeof currentValue === "string"
? currentValue
: undefined;
if (normalizedCurrent !== expected[key]) return false;
}
return true;
}
export function useControlPlane() {
const router = useRouter();
const route = useRoute();
const token = ref(getToken());
const username = ref("admin");
const password = ref("");
const authMessage = ref("");
const error = ref("");
const notice = ref("");
const loading = ref(false);
const servicesLoading = ref(false);
const proxies = ref<ProxyDocument[]>([]);
const services = ref<DockerService[]>([]);
const options = ref<MitmOption[]>([]);
@@ -113,21 +168,153 @@ export function useControlPlane() {
const selectedPort = ref<number | null>(null);
const health = ref<HealthResult | null>(null);
const logs = ref("");
const logFilter = ref("");
const logsPaused = ref(false);
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 scanRoot = ref(readSession(scanRootStorageKey, "/root"));
const optionScope = ref<OptionScope>(
readSession(optionScopeStorageKey, "focused") as OptionScope,
);
const selectedOptionName = ref("");
const selectedExampleId = ref("");
const draft = reactive<ProxyDraft>(createDefaultDraft());
const loadedPort = ref<number | null>(null);
const wizardOpen = ref(false);
const wizardMode = ref<"create" | "edit">("create");
const wizardStep = ref(0);
let noticeTimer: number | null = null;
let logPollTimer: number | null = null;
let syncingRoute = false;
const selectedProxy = computed(() =>
proxies.value.find((proxy) => proxy.proxy_port === selectedPort.value),
);
const runtimeStatus = computed(() => selectedProxy.value?.status ?? "draft");
const isRunning = computed(() =>
["running", "starting", "restarting"].includes(runtimeStatus.value),
);
const actionState = computed(() => ({
canSave: !loading.value && !isRunning.value,
canStart: !loading.value && Boolean(selectedProxy.value) && !isRunning.value,
canStop: !loading.value && Boolean(selectedProxy.value) && isRunning.value,
canRestart: !loading.value && Boolean(selectedProxy.value) && isRunning.value,
canDelete: !loading.value && Boolean(selectedProxy.value),
showSave: !isRunning.value,
showStart: Boolean(selectedProxy.value) && !isRunning.value,
showStop: Boolean(selectedProxy.value) && isRunning.value,
showRestart: Boolean(selectedProxy.value) && isRunning.value,
}));
const wizardTitle = computed(() =>
wizardMode.value === "edit" ? "Edit proxy settings" : "Create proxy",
);
const wizardStepTitle = computed(() => {
return ["Proxy route", "Healthchecks", "Custom mitmdump options"][
wizardStep.value
] ?? "Proxy route";
});
const canGoBackWizard = computed(() => wizardStep.value > 0);
const canGoNextWizard = computed(() => wizardStep.value < 2);
const filteredLogs = computed(() => {
const source = logs.value || "";
const query = logFilter.value.trim().toLowerCase();
if (!query) return source;
return source
.split("\n")
.filter((line) => line.toLowerCase().includes(query))
.join("\n");
});
function buildRouteQuery() {
const query: Record<string, string | undefined> = {
scanRoot: scanRoot.value.trim() && scanRoot.value.trim() !== "/root"
? scanRoot.value.trim()
: undefined,
};
if (wizardOpen.value) {
query.modal = wizardMode.value;
query.step = String(wizardStep.value);
}
return query;
}
async function syncRouteState() {
if (syncingRoute) return;
const path = selectedPort.value ? `/proxies/${selectedPort.value}` : "/proxies";
const query = buildRouteQuery();
if (route.path === path && sameQuery(route.query as Record<string, unknown>, query)) {
return;
}
syncingRoute = true;
try {
await router.replace({ path, query });
} finally {
syncingRoute = false;
}
}
function applyRouteState() {
if (syncingRoute) return;
syncingRoute = true;
try {
selectedPort.value = parseProxyPort(route.params.proxyPort);
const routedScanRoot =
typeof route.query.scanRoot === "string" ? route.query.scanRoot : null;
scanRoot.value = routedScanRoot || readSession(scanRootStorageKey, "/root");
const modal = parseWizardMode(route.query.modal);
const step = parseWizardStep(route.query.step);
if (modal === "create") {
prepareDraftForCreate();
wizardMode.value = "create";
wizardStep.value = step;
wizardOpen.value = true;
} else if (modal === "edit" && selectedPort.value !== null) {
wizardMode.value = "edit";
wizardStep.value = step;
wizardOpen.value = true;
} else {
wizardOpen.value = false;
wizardStep.value = 0;
}
} finally {
syncingRoute = false;
}
}
const serviceEntries = computed(() =>
services.value.flatMap((service) =>
bindingsFor(service).map((binding) => {
const proxy =
proxies.value.find(
(item) =>
item.service.name === service.name &&
item.service.folder === service.folder &&
item.service.port === binding.published,
) ?? null;
return {
id: `${service.folder}:${service.name}:${binding.host ?? "*"}:${binding.published}`,
service,
binding,
proxy,
listenHost: binding.host ?? "0.0.0.0",
};
}),
),
);
const missingServiceCount = computed(
() => serviceEntries.value.filter((entry) => !entry.proxy).length,
);
const routeState = computed(() => {
const livePort =
selectedProxy.value?.service.upstream_port ?? draft.service.upstream_port ?? null;
@@ -136,27 +323,43 @@ export function useControlPlane() {
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}`;
const takeoverReady =
reverseManaged && dockerManaged && draft.proxy_port === draft.service.port;
const takeoverMismatch =
reverseManaged && dockerManaged && draft.proxy_port !== draft.service.port;
let startNotice = "Save stores configuration only. Start applies runtime routing.";
if (reverseManaged && dockerManaged) {
let startNotice: string | null = null;
if (takeoverReady) {
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}.`;
`Start will move the real service from :${draft.service.port} to a temporary upstream port and put mitmdump on :${draft.proxy_port}.`;
} else if (takeoverMismatch) {
startNotice =
`Start will move the real service away from :${draft.service.port}, but this proxy is listening on :${draft.proxy_port}. Set the proxy port to :${draft.service.port} if you want mitmdump to replace the original route.`;
} else if (draft.mode_spec) {
startNotice =
"This proxy uses a raw mode specification. Start will follow the custom mitmdump mode exactly as configured.";
"Raw mode spec is enabled. Start will use the exact mitmdump mode string you configured.";
} else if (draft.mode !== "reverse") {
startNotice =
"This mode does not use Docker port remapping. Start only launches the configured mitmdump listener.";
"This mode does not remap the Docker service port. Start only launches the configured mitmdump listener.";
}
let liveNotice = "No runtime remap is active.";
let liveNotice: string | null = null;
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.";
} else if (takeoverMismatch) {
liveNotice = "The service will be moved away on Start, but the original route is not taken over unless the proxy port matches the service port.";
}
const summaryHint = takeoverMismatch
? startNotice
: remapped
? liveNotice
: draft.mode_spec
? startNotice
: draft.mode !== "reverse"
? startNotice
: null;
return {
configuredListen: `${draft.listen_host}:${draft.proxy_port}`,
configuredTarget,
@@ -167,26 +370,35 @@ export function useControlPlane() {
reverseManaged,
remapped,
livePort,
takeoverReady,
takeoverMismatch,
startNotice,
liveNotice,
summaryHint,
};
});
const filteredOptions = computed(() => {
const query = optionSearch.value.toLowerCase().trim();
const available = options.value.filter((option) => !option.reserved);
const filtered = available
const scoped = query
? available
: optionScope.value === "all"
? available
: available.filter((option) => focusedMitmOptions.has(option.name));
const filtered = scoped
.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);
return filtered.slice(0, query ? 80 : optionScope.value === "all" ? 80 : 18);
});
const optionCount = computed(
() => options.value.filter((option) => !option.reserved).length,
);
const activeOptionCount = computed(() => Object.keys(draft.mitm_options).length);
const selectedOption = computed(
() =>
@@ -226,19 +438,43 @@ export function useControlPlane() {
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(
[selectedPort, proxies],
([port]) => {
if (port === null || port === loadedPort.value) return;
const proxy = proxies.value.find((item) => item.proxy_port === port);
if (!proxy) return;
hydrateDraft(proxy);
},
{ immediate: true },
);
watch(
() => [
route.params.proxyPort,
route.query.modal,
route.query.step,
route.query.scanRoot,
],
() => {
applyRouteState();
},
{ immediate: true },
);
watch(
[selectedPort, wizardOpen, wizardMode, wizardStep, scanRoot],
() => {
void syncRouteState();
},
);
watch(optionScope, (value) => {
writeSession(optionScopeStorageKey, value);
});
watch(editorOpen, (value) => {
writeSession(editorStorageKey, value ? "1" : "0");
});
watch(toolView, (value) => {
writeSession(toolStorageKey, value);
watch(scanRoot, (value) => {
writeSession(scanRootStorageKey, value);
});
watch(filteredOptions, (items) => {
@@ -253,60 +489,196 @@ export function useControlPlane() {
}
}, { immediate: true });
watch(selectedProxy, (proxy) => {
if (!proxy) return;
if (!wizardOpen.value) {
hydrateDraft(proxy);
}
});
watch(
[selectedPort, token, logsPaused],
([port, authToken, paused]) => {
stopLogPolling();
if (!authToken || port === null || paused) return;
void syncLogs(true);
if (typeof window === "undefined") return;
logPollTimer = window.setInterval(() => {
void syncLogs(true);
}, 2000);
},
{ immediate: true },
);
async function signIn() {
await run(async () => {
await login(username.value, password.value);
token.value = getToken();
authMessage.value = "";
password.value = "";
await refreshAll();
}, "Logged in");
}
async function signOut() {
await logout();
token.value = null;
proxies.value = [];
if (token.value) {
await logout();
}
clearSession();
}
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;
servicesLoading.value = true;
try {
const [proxyList, serviceResponse, optionList, exampleList] =
await Promise.all([
api.proxies(),
api.services(scanRoot.value.trim() || undefined),
api.options(),
api.examples(),
]);
proxies.value = proxyList;
services.value = serviceResponse.services;
scanRoot.value = serviceResponse.scan_root;
options.value = optionList;
examples.value = exampleList;
if (!proxyList.some((proxy) => proxy.proxy_port === selectedPort.value)) {
selectedPort.value = null;
}
} finally {
servicesLoading.value = false;
}
});
}
function newProxy() {
async function refreshServices() {
servicesLoading.value = true;
error.value = "";
try {
const response = await api.services(scanRoot.value.trim() || undefined);
scanRoot.value = response.scan_root;
services.value = response.services;
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
} finally {
servicesLoading.value = false;
}
}
function prepareDraftForCreate(announce = false) {
selectedPort.value = null;
loadedPort.value = null;
health.value = null;
logs.value = "";
logFilter.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";
if (announce) {
notice.value = "Draft created.";
}
}
function newProxy() {
prepareDraftForCreate(true);
}
function openCreateWizard(
service?: DockerService,
binding?: DockerServiceBinding,
) {
prepareDraftForCreate();
if (service && binding) {
Object.assign(draft, draftForService(service, binding));
}
wizardMode.value = "create";
wizardStep.value = 0;
wizardOpen.value = true;
void syncRouteState();
}
function openEditWizard(proxy = selectedProxy.value) {
if (!proxy) return;
selectedPort.value = proxy.proxy_port;
hydrateDraft(proxy);
wizardMode.value = "edit";
wizardStep.value = 0;
wizardOpen.value = true;
void syncRouteState();
}
function closeWizard() {
if (wizardMode.value === "edit" && selectedProxy.value) {
hydrateDraft(selectedProxy.value);
} else if (wizardMode.value === "create") {
prepareDraftForCreate();
}
wizardOpen.value = false;
wizardStep.value = 0;
void syncRouteState();
}
function nextWizardStep() {
wizardStep.value = Math.min(wizardStep.value + 1, 2);
void syncRouteState();
}
function previousWizardStep() {
wizardStep.value = Math.max(wizardStep.value - 1, 0);
void syncRouteState();
}
async function createProxyFromService(
service: DockerService,
binding: DockerServiceBinding,
) {
openCreateWizard(service, binding);
}
async function createMissingServiceProxies() {
await run(async () => {
for (const entry of serviceEntries.value) {
if (entry.proxy) continue;
await api.createProxy(draftForService(entry.service, entry.binding));
}
await refreshAll();
const first = proxies.value[0];
if (first) {
selectedPort.value = first.proxy_port;
hydrateDraft(first);
}
}, "Missing proxies created");
}
async function saveProxy() {
let persisted = false;
await run(async () => {
const payload = cleanDraft();
const saved = selectedProxy.value
? await api.updateProxy(selectedProxy.value.proxy_port, payload)
? await api.updateProxy(selectedProxy.value.proxy_port, {
name: payload.name,
code: payload.code,
mode: payload.mode,
mode_spec: payload.mode_spec,
protocol: payload.protocol,
listen_host: payload.listen_host,
mitm_options: payload.mitm_options,
healthcheck: payload.healthcheck,
service: payload.service,
})
: await api.createProxy(payload);
await refreshAll();
selectedPort.value = saved.proxy_port;
const refreshed = proxies.value.find((proxy) => proxy.proxy_port === saved.proxy_port);
hydrateDraft(refreshed ?? saved);
persisted = true;
}, "Proxy saved");
if (persisted) {
wizardOpen.value = false;
wizardStep.value = 0;
}
}
async function removeProxy() {
@@ -315,6 +687,7 @@ export function useControlPlane() {
await run(async () => {
await api.deleteProxy(proxy.proxy_port);
selectedPort.value = null;
loadedPort.value = null;
await refreshAll();
}, "Proxy deleted");
}
@@ -330,8 +703,10 @@ export function useControlPlane() {
: kind === "stop"
? await api.stop(port)
: await api.restart(port);
replaceProxy(updated);
await refreshAll();
selectedPort.value = updated.proxy_port;
const refreshed = proxies.value.find((item) => item.proxy_port === updated.proxy_port);
hydrateDraft(refreshed ?? updated);
}, `Proxy ${kind} requested`);
}
@@ -344,11 +719,19 @@ export function useControlPlane() {
}
async function loadLogs() {
await syncLogs(false);
}
async function syncLogs(silent = false) {
const proxy = selectedProxy.value;
if (!proxy) return;
await run(async () => {
try {
logs.value = (await api.logs(proxy.proxy_port)).log;
});
} catch (err) {
if (!silent) {
error.value = err instanceof Error ? err.message : String(err);
}
}
}
async function saveCurrentAsExample() {
@@ -364,7 +747,7 @@ export function useControlPlane() {
await api.createExample({
id,
name,
description: `Captured from ${draft.mode} ${draft.protocol} proxy ${draft.name}.`,
description: `Saved from ${draft.name} (${draft.mode} / ${draft.protocol}).`,
category: "custom",
code: draft.code,
source_url: null,
@@ -383,19 +766,38 @@ export function useControlPlane() {
}, "Custom example deleted");
}
function applyService(service: DockerService, port: number) {
function applyService(
service: DockerService,
port: number,
bindHost: string | null = null,
) {
const shouldFollowServicePort =
!selectedProxy.value || draft.proxy_port === draft.service.port;
draft.service.name = service.name;
draft.service.folder = service.folder;
draft.service.port = port;
draft.service.bind_host = bindHost;
draft.service.host = "127.0.0.1";
draft.listen_host = bindHost ?? "0.0.0.0";
if (draft.mode === "reverse" && !draft.mode_spec && shouldFollowServicePort) {
draft.proxy_port = port;
}
}
function applyExample(example: AddonExample) {
if (
draft.code.trim() &&
draft.code.trim() !== example.code.trim() &&
typeof window !== "undefined" &&
!window.confirm(
"Applying this template will overwrite your current proxy script. Continue?",
)
) {
return;
}
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.`;
notice.value = `Loaded ${example.name}.`;
}
function selectExample(exampleId: string) {
@@ -436,12 +838,20 @@ export function useControlPlane() {
draft.mitm_options[name] = value;
}
function toggleEditor(force?: boolean) {
editorOpen.value = typeof force === "boolean" ? force : !editorOpen.value;
function matchProxyPortToService() {
draft.proxy_port = draft.service.port;
}
function setToolView(view: ToolView) {
toolView.value = view;
function toggleOptionScope(scope: OptionScope) {
optionScope.value = scope;
}
function toggleLogsPaused() {
logsPaused.value = !logsPaused.value;
}
function clearLogs() {
logs.value = "";
}
function cleanDraft(): ProxyDraft {
@@ -463,6 +873,7 @@ export function useControlPlane() {
service: {
host: draft.service.host,
port: draft.service.port,
bind_host: draft.service.bind_host || null,
name: draft.service.name || null,
folder: draft.service.folder || null,
upstream_port: draft.service.upstream_port || null,
@@ -471,12 +882,35 @@ export function useControlPlane() {
) 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 hydrateDraft(proxy: ProxyDocument) {
Object.assign(draft, JSON.parse(JSON.stringify(proxy)) as ProxyDraft);
loadedPort.value = proxy.proxy_port;
health.value = null;
logs.value = "";
}
function bindingsFor(service: DockerService) {
return service.bindings?.length
? service.bindings
: service.ports.map((published) => ({ published, host: null }));
}
function draftForService(service: DockerService, binding: DockerServiceBinding) {
const template = createDefaultDraft(binding.published, examples.value[0]?.code);
const portSuffix = bindingsFor(service).length > 1 ? `-${binding.published}` : "";
return {
...template,
name: `${service.name}${portSuffix}`,
listen_host: binding.host ?? "0.0.0.0",
service: {
host: "127.0.0.1",
port: binding.published,
bind_host: binding.host ?? null,
name: service.name,
folder: service.folder,
upstream_port: null,
},
} satisfies ProxyDraft;
}
function nextPort() {
@@ -497,26 +931,83 @@ export function useControlPlane() {
async function run(task: () => Promise<void>, success?: string) {
loading.value = true;
error.value = "";
notice.value = "";
clearNotice();
try {
await task();
if (success) notice.value = success;
if (success) showNotice(success);
} catch (err) {
if (err instanceof ApiError && err.status === 401) {
clearSession("Session expired. Sign in again.");
return;
}
error.value = err instanceof Error ? err.message : String(err);
} finally {
loading.value = false;
}
}
function showNotice(message: string, timeout = 2600) {
notice.value = message;
if (typeof window === "undefined") return;
if (noticeTimer !== null) window.clearTimeout(noticeTimer);
noticeTimer = window.setTimeout(() => {
notice.value = "";
noticeTimer = null;
}, timeout);
}
function clearNotice() {
notice.value = "";
if (typeof window === "undefined" || noticeTimer === null) return;
window.clearTimeout(noticeTimer);
noticeTimer = null;
}
function clearSession(message = "") {
stopLogPolling();
setToken(null);
token.value = null;
proxies.value = [];
services.value = [];
options.value = [];
examples.value = [];
selectedPort.value = null;
loadedPort.value = null;
health.value = null;
logs.value = "";
logFilter.value = "";
wizardOpen.value = false;
wizardStep.value = 0;
wizardMode.value = "create";
Object.assign(draft, createDefaultDraft());
authMessage.value = message;
void router.replace({ path: "/proxies", query: {} });
}
function stopLogPolling() {
if (typeof window === "undefined" || logPollTimer === null) return;
window.clearInterval(logPollTimer);
logPollTimer = null;
}
return proxyRefs({
activeOptionCount,
activeOptions,
applyExample,
applyService,
actionState,
authMessage,
checkHealth,
clearLogs,
closeWizard,
codeLineCount,
filteredLogs,
logFilter,
logsPaused,
createMissingServiceProxies,
createProxyFromService,
customExampleName,
draft,
editorOpen,
error,
exampleSearch,
filteredExamples,
@@ -527,15 +1018,21 @@ export function useControlPlane() {
logs,
newProxy,
notice,
openCreateWizard,
openEditWizard,
optionCount,
optionScope,
optionSearch,
password,
previousWizardStep,
proxies,
refreshServices,
refreshAll,
removeExample,
removeOption,
removeProxy,
routeState,
runtimeStatus,
runtimeAction,
saveCurrentAsExample,
saveProxy,
@@ -545,16 +1042,28 @@ export function useControlPlane() {
selectedPort,
selectedProxy,
selectOption,
serviceEntries,
services,
setOption,
setToolView,
signIn,
signOut,
toggleEditor,
matchProxyPortToService,
missingServiceCount,
nextWizardStep,
toggleOptionScope,
toggleLogsPaused,
token,
toolView,
updateOption,
username,
wizardMode,
wizardOpen,
wizardStep,
wizardStepTitle,
wizardTitle,
canGoBackWizard,
canGoNextWizard,
scanRoot,
servicesLoading,
});
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { createApp } from "vue";
import App from "./App.vue";
import { router } from "./router";
import "./styles.css";
createApp(App).mount("#app");
createApp(App).use(router).mount("#app");
+13
View File
@@ -0,0 +1,13 @@
import { createRouter, createWebHistory } from "vue-router";
const RouteAnchor = { template: "<div />" };
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/", redirect: "/proxies" },
{ path: "/proxies", component: RouteAnchor },
{ path: "/proxies/:proxyPort(\\d+)", component: RouteAnchor },
{ path: "/:pathMatch(.*)*", redirect: "/proxies" },
],
});
+509 -123
View File
@@ -2,23 +2,23 @@
:root {
color-scheme: dark;
--bg: #08090b;
--bg-elevated: #0d1014;
--panel: #11151a;
--bg: #090b0d;
--bg-elevated: #0f1317;
--panel: #11161b;
--panel-2: #151b21;
--line: #252d35;
--line-strong: #33404d;
--text: #eef3f7;
--muted: #9da8b2;
--accent: #9be7ff;
--accent-2: #c6f2ff;
--ok: #8ef0b0;
--warn: #ffd88c;
--danger: #ff9f9f;
--radius-lg: 14px;
--radius-md: 10px;
--radius-sm: 8px;
--shadow: 0 20px 40px rgba(0, 0, 0, 0.22);
--line: #252c34;
--line-strong: #36414c;
--text: #f1f5f8;
--muted: #9aa6b2;
--accent: #a8ecff;
--accent-2: #d5f7ff;
--ok: #8ce7af;
--warn: #ffd585;
--danger: #ff9d9d;
--radius-lg: 8px;
--radius-md: 6px;
--radius-sm: 4px;
--shadow: none;
}
* {
@@ -39,11 +39,7 @@ body {
margin: 0;
color: var(--text);
font-family: "IBM Plex Sans", sans-serif;
background:
linear-gradient(rgba(255, 255, 255, 0.015) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.015) 1px, transparent 1px),
var(--bg);
background-size: 28px 28px, 28px 28px, auto;
background: var(--bg);
}
button,
@@ -72,6 +68,24 @@ button {
background: #171d24;
}
.button-icon {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.ui-icon {
width: 0.95rem;
height: 0.95rem;
fill: none;
stroke: currentColor;
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
flex: 0 0 auto;
}
button:hover:not(:disabled) {
border-color: var(--line-strong);
background: #1c232b;
@@ -96,6 +110,26 @@ button.ghost {
background: #12171d;
}
button.success {
color: #08120c;
background: var(--ok);
border-color: #5fb37d;
}
button.success:hover:not(:disabled) {
background: #a6f0bf;
}
button.warning {
color: #161006;
background: var(--warn);
border-color: #c9a24c;
}
button.warning:hover:not(:disabled) {
background: #ffe19e;
}
button.danger {
color: #ffe1e1;
background: #2b1717;
@@ -201,7 +235,6 @@ pre {
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: var(--panel);
box-shadow: var(--shadow);
}
.app-header {
@@ -262,7 +295,7 @@ pre {
.app-body {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
grid-template-columns: 250px minmax(0, 1fr);
gap: 1rem;
align-items: start;
margin-top: 1rem;
@@ -278,7 +311,7 @@ pre {
.panel-title h2,
.workbench-copy h1,
.login-card h1 {
margin: 0.2rem 0 0;
margin: 0;
font-size: clamp(1.18rem, 1.7vw, 1.5rem);
line-height: 1.1;
}
@@ -343,8 +376,8 @@ pre {
}
.proxy-card.selected {
border-color: #4f6b7c;
background: #151c23;
border-color: #4e6272;
background: #161d24;
}
.proxy-card-head,
@@ -402,7 +435,6 @@ pre {
}
.workbench-head,
.summary-panel,
.editor-pane,
.stack-panel {
padding: 1rem;
@@ -454,8 +486,7 @@ pre {
.action-row,
.doc-actions,
.template-actions,
.tool-switcher {
.template-actions {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
@@ -486,22 +517,27 @@ pre {
border-color: #2f4d60;
}
.app-toast {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 30;
min-width: 220px;
max-width: 360px;
padding: 0.85rem 1rem;
border: 1px solid #2f4d60;
border-radius: var(--radius-md);
background: rgba(21, 32, 40, 0.96);
color: #ddf7ff;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.28);
backdrop-filter: blur(8px);
}
.compact {
display: inline-block;
width: fit-content;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.summary-panel {
display: grid;
gap: 0.85rem;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -523,6 +559,12 @@ pre {
line-height: 1.35;
}
.route-overview-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.metric-label {
color: var(--muted);
font-size: 0.75rem;
@@ -530,23 +572,27 @@ pre {
letter-spacing: 0.08em;
}
.summary-lines {
.workspace-main-row {
display: grid;
gap: 0.55rem;
color: var(--muted);
}
.workbench-grid {
display: grid;
grid-template-columns: minmax(320px, 0.85fr) minmax(0, 1.15fr);
grid-template-columns: minmax(0, 5fr) minmax(320px, 3fr);
gap: 1rem;
align-items: start;
}
.overview-stack,
.utility-panel {
.workspace-editor-pane,
.workspace-logs-pane,
.workspace-templates-row {
min-width: 0;
}
.workspace-editor-pane,
.workspace-logs-pane {
display: grid;
gap: 1rem;
}
.wizard-step-shell {
display: grid;
gap: 0.9rem;
}
.compact-panel {
@@ -597,37 +643,14 @@ pre {
accent-color: var(--accent);
}
.service-grid {
display: grid;
gap: 0.7rem;
}
.service-chip {
display: grid;
gap: 0.5rem;
padding: 0.75rem;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--bg-elevated);
}
.port-row {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.port-chip {
padding: 0.38rem 0.6rem;
background: #0d1116;
.service-folder {
display: block;
overflow: hidden;
color: var(--muted);
font-family: "IBM Plex Mono", monospace;
font-size: 0.8rem;
}
.port-chip.active {
color: #071117;
background: var(--accent);
border-color: #7dcfe7;
font-size: 0.74rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.compact-details {
@@ -688,14 +711,29 @@ pre {
font-size: 0.8rem;
}
.tool-switcher button {
background: #0f1318;
.title-actions,
.segmented {
display: flex;
align-items: center;
gap: 0.55rem;
}
.tool-switcher button.active {
color: #081016;
.segmented {
padding: 0.2rem;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: #0d1115;
}
.segmented button {
padding: 0.45rem 0.7rem;
border-color: transparent;
background: transparent;
}
.segmented button.active {
color: #071116;
background: var(--accent);
border-color: #7dcfe7;
}
.tool-panel {
@@ -710,7 +748,7 @@ pre {
.table-head,
.table-row {
display: grid;
grid-template-columns: 180px minmax(0, 1fr) auto;
grid-template-columns: 150px minmax(0, 1fr) auto;
gap: 0.7rem;
align-items: center;
}
@@ -729,11 +767,19 @@ pre {
overflow: auto;
}
.compact-scroll {
max-height: 180px;
}
.table-row {
padding: 0.6rem 0;
border-top: 1px solid var(--line);
}
.table-row input {
min-width: 0;
}
.table-link {
justify-self: start;
padding: 0;
@@ -751,15 +797,62 @@ pre {
.option-shell,
.example-shell {
display: grid;
grid-template-columns: minmax(220px, 0.7fr) minmax(0, 1fr);
grid-template-columns: 260px minmax(0, 1fr);
gap: 0.9rem;
min-height: 0;
}
.option-step-layout {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
gap: 1rem;
align-items: start;
}
.option-step-main {
display: grid;
gap: 0.9rem;
min-width: 0;
}
.option-browser,
.example-browser,
.option-doc,
.example-preview {
display: grid;
align-content: start;
gap: 0.75rem;
padding: 0.85rem;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--bg-elevated);
}
.option-browser-embedded,
.option-doc-embedded,
.option-active-card {
min-height: 0;
}
.option-toolbar {
display: grid;
gap: 0.75rem;
}
.option-browser-meta,
.option-active-head {
display: flex;
gap: 0.75rem;
align-items: center;
justify-content: space-between;
}
.option-browser-meta p,
.option-active-head h3 {
margin: 0;
}
.option-active-card {
display: grid;
gap: 0.75rem;
padding: 0.85rem;
@@ -768,11 +861,39 @@ pre {
background: var(--bg-elevated);
}
.option-doc-embedded .option-doc-head {
gap: 1rem;
}
.doc-facts-compact {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.option-search-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.65rem;
align-items: center;
}
.option-count {
display: inline-grid;
place-items: center;
min-width: 1.8rem;
min-height: 1.8rem;
padding: 0 0.45rem;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--muted);
font-family: "IBM Plex Mono", monospace;
font-size: 0.76rem;
}
.option-table,
.example-index {
display: grid;
gap: 0.45rem;
max-height: 380px;
max-height: 320px;
overflow: auto;
}
@@ -780,7 +901,7 @@ pre {
.example-entry {
width: 100%;
display: grid;
gap: 0.18rem;
gap: 0.2rem;
text-align: left;
padding: 0.65rem 0.75rem;
background: #0d1116;
@@ -820,25 +941,67 @@ pre {
line-height: 1.55;
}
.compact-copy {
max-width: 70ch;
}
.example-snippet {
color: var(--muted);
font-size: 0.78rem;
line-height: 1.45;
}
.doc-facts,
.template-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
display: grid;
gap: 0.55rem;
}
.doc-facts span {
padding: 0.22rem 0.45rem;
border-radius: 999px;
background: #0d1116;
.doc-facts {
margin: 0;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.doc-fact {
display: grid;
gap: 0.2rem;
padding: 0.55rem 0.65rem;
border: 1px solid var(--line);
color: var(--muted);
font-size: 0.76rem;
border-radius: var(--radius-sm);
background: #0d1116;
}
.logs-panel .terminal {
min-height: 300px;
max-height: 420px;
.doc-fact dt {
color: var(--muted);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.doc-fact dd {
margin: 0;
color: var(--text);
font-family: "IBM Plex Mono", monospace;
font-size: 0.8rem;
}
.doc-actions {
align-items: center;
}
.button-link {
display: inline-flex;
align-items: center;
gap: 0.45rem;
}
.table-action {
padding: 0.55rem 0.7rem;
}
.template-preview-terminal {
min-height: 260px;
max-height: 360px;
}
.terminal {
@@ -855,6 +1018,7 @@ pre {
.editor-pane {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 0.85rem;
}
@@ -870,7 +1034,7 @@ pre {
}
.editor-shell {
min-height: 620px;
min-height: clamp(420px, 68vh, 760px);
border: 1px solid var(--line);
border-radius: var(--radius-md);
overflow: hidden;
@@ -879,28 +1043,219 @@ pre {
.code-editor-wrap {
display: grid;
grid-template-rows: auto 1fr;
min-height: 620px;
}
.code-editor-status {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.7rem 0.9rem;
border-bottom: 1px solid var(--line);
background: #0f1418;
color: var(--muted);
font-family: "IBM Plex Mono", monospace;
font-size: 0.78rem;
height: 100%;
min-height: clamp(420px, 68vh, 760px);
}
.code-editor {
width: 100%;
height: 100%;
min-height: 0;
}
.logs-panel {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
max-height: clamp(420px, 68vh, 760px);
overflow: hidden;
}
.logs-panel .terminal {
min-height: 0;
height: 100%;
max-height: 100%;
overflow: auto;
}
.editor-error,
.field-error {
margin: 0;
padding: 0.7rem 0.85rem;
color: #ffd9d9;
background: #241617;
border-bottom: 1px solid #5c2c2c;
font-size: 0.82rem;
}
.editor-placeholder {
display: grid;
gap: 0.85rem;
min-height: 180px;
padding: 1rem;
}
.route-summary {
align-items: center;
}
.route-strip {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
align-items: center;
}
.route-chip {
display: inline-flex;
align-items: center;
min-height: 1.9rem;
padding: 0.35rem 0.6rem;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: #0d1116;
color: var(--text);
font-family: "IBM Plex Mono", monospace;
font-size: 0.81rem;
}
.route-form-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.single-row-grid {
grid-template-columns: minmax(0, 1fr);
}
.advanced-inline-action {
align-items: center;
}
.service-discovery-list {
display: grid;
gap: 0.65rem;
}
.service-discovery-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: center;
padding: 0.8rem 0.85rem;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--bg-elevated);
}
.service-discovery-main {
display: grid;
gap: 0.2rem;
min-width: 0;
}
.service-route-copy {
color: var(--text);
font-family: "IBM Plex Mono", monospace;
font-size: 0.82rem;
}
.service-discovery-actions {
display: flex;
align-items: center;
gap: 0.55rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 40;
display: grid;
place-items: center;
padding: 1rem;
background: rgba(6, 8, 10, 0.72);
backdrop-filter: blur(4px);
}
.modal-shell {
width: min(980px, 100%);
max-height: calc(100vh - 2rem);
display: grid;
gap: 1rem;
overflow: auto;
padding: 1rem;
border: 1px solid var(--line-strong);
border-radius: var(--radius-lg);
background: var(--panel);
}
.modal-head,
.modal-actions {
display: flex;
align-items: center;
gap: 0.75rem;
justify-content: space-between;
}
.modal-head-actions {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
justify-content: flex-end;
}
.modal-head h2 {
margin: 0;
font-size: 1.3rem;
}
.modal-step-copy {
margin: 0.35rem 0 0;
color: var(--muted);
}
.modal-stepper {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
}
.modal-step-chip {
display: grid;
gap: 0.25rem;
justify-items: start;
text-align: left;
padding: 0.8rem;
background: #0f1318;
}
.modal-step-chip span {
display: inline-grid;
place-items: center;
width: 1.7rem;
height: 1.7rem;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--muted);
font-family: "IBM Plex Mono", monospace;
font-size: 0.76rem;
}
.modal-step-chip.active {
border-color: #7dcfe7;
background: #141c22;
}
.modal-step-chip.active span,
.modal-step-chip.done span {
color: #071117;
background: var(--accent);
border-color: #7dcfe7;
}
.modal-body {
display: grid;
gap: 1rem;
}
.modal-action-spacer {
flex: 1 1 auto;
}
.auth-alert {
margin-top: 0.2rem;
}
.logo {
width: 44px;
height: 44px;
@@ -930,8 +1285,8 @@ pre {
background: transparent;
}
@media (max-width: 1320px) {
.workbench-grid {
@media (max-width: 1180px) {
.option-step-layout {
grid-template-columns: 1fr;
}
@@ -941,9 +1296,9 @@ pre {
}
}
@media (max-width: 1120px) {
@media (max-width: 980px) {
.app-body,
.summary-grid {
.workspace-main-row {
grid-template-columns: 1fr;
}
@@ -951,6 +1306,10 @@ pre {
position: static;
max-height: none;
}
.logs-panel .terminal {
min-height: 0;
}
}
@media (max-width: 860px) {
@@ -967,9 +1326,13 @@ pre {
.form-grid,
.metric-grid,
.route-overview-grid,
.doc-facts-compact,
.table-head,
.table-row,
.health-row {
.health-row,
.doc-facts,
.route-form-grid {
grid-template-columns: 1fr;
}
@@ -982,6 +1345,21 @@ pre {
.code-editor {
min-height: 500px;
}
.modal-head,
.modal-actions {
align-items: flex-start;
flex-direction: column;
}
.modal-head-actions {
width: 100%;
justify-content: flex-start;
}
.modal-stepper {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
@@ -1000,4 +1378,12 @@ pre {
align-items: flex-start;
flex-direction: column;
}
.service-discovery-row {
grid-template-columns: 1fr;
}
.service-discovery-actions {
justify-content: flex-start;
}
}
+12
View File
@@ -1,11 +1,17 @@
export type ServiceEndpoint = {
host: string;
port: number;
bind_host?: string | null;
name?: string | null;
folder?: string | null;
upstream_port?: number | null;
};
export type DockerServiceBinding = {
published: number;
host?: string | null;
};
export type HealthCheck = {
enabled: boolean;
protocol?: string | null;
@@ -42,9 +48,15 @@ export type ProxyDocument = {
export type DockerService = {
name: string;
ports: number[];
bindings?: DockerServiceBinding[];
folder: string;
};
export type ServiceDiscoveryResponse = {
scan_root: string;
services: DockerService[];
};
export type MitmOption = {
name: string;
type: string;
+1 -1
View File
@@ -10,7 +10,7 @@ export default defineConfig({
port: 5173,
proxy: {
"/api": {
target: "http://127.0.0.1:3671",
target: "http://127.0.0.1:3771",
changeOrigin: true,
ws: true,
rewrite: (path) => path.replace(/^\/api/, ""),