feat: enhance service discovery and proxy management
This commit is contained in:
Generated
+23
-1
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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: () =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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/, ""),
|
||||
|
||||
Reference in New Issue
Block a user