feat: troppa roba davvero

This commit is contained in:
gabdevele
2026-05-07 09:21:45 +02:00 Unverified
parent d977a953cc
commit 53dcdafdb1
73 changed files with 4772 additions and 960 deletions
+3 -1
View File
@@ -25,7 +25,7 @@ onMounted(() => {
v-model:username="plane.username"
v-model:password="plane.password"
:loading="plane.loading"
:error="plane.authMessage || plane.error"
:error="plane.authMessage || plane.loginError"
@submit="plane.signIn"
/>
@@ -34,6 +34,8 @@ onMounted(() => {
<AppHeader
:loading="plane.loading"
:show-discovery="Boolean(plane.selectedProxy)"
@discovery="plane.openDiscovery"
@refresh="plane.refreshAll"
@create="plane.openCreateWizard()"
@logout="plane.signOut"
+10 -6
View File
@@ -1,10 +1,12 @@
import type {
AddonExample,
HealthHistoryResponse,
HealthResult,
MitmOption,
PythonEditorSession,
ProxyDocument,
ProxyDraft,
ProxyTemplate,
ProxyTemplateCreateInput,
ServiceDiscoveryResponse,
} from "./types";
@@ -89,16 +91,16 @@ export const api = {
`/proxy/services${scanRoot ? `?scan_root=${encodeURIComponent(scanRoot)}` : ""}`,
),
options: () => request<MitmOption[]>("/proxy/mitmproxy/options"),
examples: () => request<AddonExample[]>("/proxy/mitmproxy/examples"),
templates: () => request<ProxyTemplate[]>("/proxy/mitmproxy/templates"),
editorSession: () =>
request<PythonEditorSession>("/proxy/editor/python/session"),
createExample: (payload: AddonExample) =>
request<AddonExample>("/proxy/mitmproxy/examples", {
createTemplate: (payload: ProxyTemplateCreateInput) =>
request<ProxyTemplate>("/proxy/mitmproxy/templates", {
method: "POST",
body: JSON.stringify(payload),
}),
deleteExample: (id: string) =>
request<void>(`/proxy/mitmproxy/examples/${encodeURIComponent(id)}`, {
deleteTemplate: (id: string) =>
request<void>(`/proxy/mitmproxy/templates/${encodeURIComponent(id)}`, {
method: "DELETE",
}),
createProxy: (payload: ProxyDraft) =>
@@ -120,5 +122,7 @@ export const api = {
restart: (port: number) =>
request<ProxyDocument>(`/proxy/${port}/restart`, { method: "POST" }),
health: (port: number) => request<HealthResult>(`/proxy/${port}/health`),
healthHistory: (port: number) =>
request<HealthHistoryResponse>(`/proxy/${port}/health/history`),
logs: (port: number) => request<{ log: string }>(`/proxy/${port}/logs`),
};
+7 -1
View File
@@ -4,9 +4,11 @@ import UiIcon from "./UiIcon.vue";
defineProps<{
loading: boolean;
showDiscovery: boolean;
}>();
const emit = defineEmits<{
discovery: [];
refresh: [];
create: [];
logout: [];
@@ -19,13 +21,17 @@ const emit = defineEmits<{
<AppLogo />
<div>
<strong>thePlug</strong>
<small>proxy control plane</small>
<small>il proxy barese</small>
</div>
</div>
<div class="header-meta">
<span v-if="loading" class="muted">saving</span>
</div>
<div class="header-actions">
<button v-if="showDiscovery" class="ghost button-icon" :disabled="loading" @click="emit('discovery')">
<UiIcon name="back" />
<span>Services</span>
</button>
<button class="ghost button-icon" :disabled="loading" @click="emit('refresh')">
<UiIcon name="refresh" />
<span>Refresh</span>
-80
View File
@@ -1,80 +0,0 @@
<script setup lang="ts">
import { useControlPlaneContext } from "../composables/useControlPlane";
const plane = useControlPlaneContext();
</script>
<template>
<section class="panel stack-panel tool-panel">
<div class="panel-title">
<div><h2>Templates</h2></div>
</div>
<div class="inline-grid">
<input
v-model="plane.customExampleName"
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 templates" />
<div class="example-index">
<button
v-for="example in plane.filteredExamples"
:key="example.id"
class="example-entry"
:class="{ selected: plane.selectedExample?.id === example.id }"
@click="plane.selectExample(example.id)"
>
<strong>{{ example.name }}</strong>
<small>{{ example.category }}</small>
<span class="example-snippet">{{ example.description }}</span>
</button>
</div>
</div>
<article v-if="plane.selectedExample" class="example-preview">
<div class="option-doc-head">
<div>
<h3>{{ plane.selectedExample.name }}</h3>
</div>
<div class="template-meta">
<span class="type-chip">{{ plane.selectedExample.category }}</span>
<span class="type-chip">
{{ plane.selectedExample.built_in ? "built-in" : "custom" }}
</span>
</div>
</div>
<p class="doc-copy">{{ plane.selectedExample.description }}</p>
<pre class="terminal template-preview-terminal">{{
plane.selectedExample.code
}}</pre>
<div class="doc-actions">
<button @click="plane.applyExample(plane.selectedExample)">Use template</button>
<a
v-if="plane.selectedExample.source_url"
:href="plane.selectedExample.source_url"
target="_blank"
rel="noreferrer"
>
Source
</a>
<button
v-if="!plane.selectedExample.built_in"
class="ghost"
@click="plane.removeExample(plane.selectedExample.id)"
>
Delete
</button>
</div>
</article>
</div>
</section>
</template>
@@ -0,0 +1,274 @@
<script setup lang="ts">
import { computed } from "vue";
import { useControlPlaneContext } from "../composables/useControlPlane";
import UiIcon from "./UiIcon.vue";
const plane = useControlPlaneContext();
const latestTimestamp = computed(() => {
if (!plane.health?.timestamp) return "No checks yet";
return new Date(plane.health.timestamp).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
});
const targetRows = computed(() => {
const checks = plane.health?.checks ?? [];
return [
checks.find((check) => check.target === "proxy") ?? null,
checks.find((check) => check.target === "upstream") ?? null,
];
});
const benchmarkEnabled = computed(
() =>
plane.selectedProxy?.healthcheck.benchmark_enabled ??
plane.draft.healthcheck.benchmark_enabled,
);
function benchmarkSeries(target: "proxy" | "upstream") {
return plane.healthHistory
.map((sample, index) => {
const check = sample.checks.find((item) => item.target === target);
const duration =
typeof check?.duration_ms === "number" ? check.duration_ms : null;
if (duration === null) return null;
return {
index,
duration,
};
})
.filter((item): item is { index: number; duration: number } => item !== null);
}
function latestBenchmark(target: "proxy" | "upstream") {
const check = plane.health?.checks.find((item) => item.target === target);
return typeof check?.duration_ms === "number" ? check.duration_ms : null;
}
function averageBenchmark(target: "proxy" | "upstream") {
const values = benchmarkSeries(target).map((item) => item.duration);
if (!values.length) return null;
const total = values.reduce((sum, value) => sum + value, 0);
return Math.round((total / values.length) * 100) / 100;
}
const benchmarkSummary = computed(() => ({
proxyLast: latestBenchmark("proxy"),
proxyAvg: averageBenchmark("proxy"),
upstreamLast: latestBenchmark("upstream"),
upstreamAvg: averageBenchmark("upstream"),
}));
const chartPoints = computed(() => {
const history = plane.healthHistory;
const width = 220;
const height = 54;
const build = (target: "proxy" | "upstream") => {
const points = history
.map((sample, index) => {
const check = sample.checks.find((item) => item.target === target);
if (!check) return null;
const x = history.length === 1 ? width / 2 : (index / (history.length - 1)) * width;
const y = check.healthy ? 10 : height - 10;
return `${x},${y}`;
})
.filter(Boolean);
return points.join(" ");
};
return {
proxy: build("proxy"),
upstream: build("upstream"),
width,
height,
};
});
const benchmarkChart = computed(() => {
const width = 220;
const height = 72;
const proxySeries = benchmarkSeries("proxy");
const upstreamSeries = benchmarkSeries("upstream");
const allValues = [...proxySeries, ...upstreamSeries].map((item) => item.duration);
const maxDuration = allValues.length ? Math.max(...allValues) : 0;
const domainMax = maxDuration > 0 ? maxDuration : 1;
const toPoints = (series: { index: number; duration: number }[]) =>
series
.map((item) => {
const x =
plane.healthHistory.length <= 1
? width / 2
: (item.index / (plane.healthHistory.length - 1)) * width;
const y = height - 10 - (item.duration / domainMax) * (height - 20);
return `${x},${y}`;
})
.join(" ");
return {
width,
height,
proxy: toPoints(proxySeries),
upstream: toPoints(upstreamSeries),
hasData: allValues.length > 0,
maxDuration: Math.round(domainMax * 100) / 100,
};
});
function formatDuration(value: number | null) {
return value === null ? "n/a" : `${value.toFixed(2)} ms`;
}
</script>
<template>
<div v-if="plane.healthModalOpen" class="modal-backdrop" @click.self="plane.closeHealthModal">
<section class="modal-shell health-modal-shell">
<div class="modal-head">
<div>
<h2>Health</h2>
<p class="modal-step-copy">
Runtime checks for the live proxy path and the real upstream service.
</p>
</div>
<div class="modal-head-actions">
<button class="ghost button-icon" :disabled="plane.loading" @click="plane.checkHealth">
<UiIcon name="pulse" />
<span>Run now</span>
</button>
<button class="ghost button-icon" @click="plane.closeHealthModal">
<UiIcon name="close" />
<span>Close</span>
</button>
</div>
</div>
<div class="modal-body health-modal-body">
<div
class="health-runtime-overview"
:class="plane.health?.overall ?? 'disabled'"
>
<strong>{{ plane.health?.overall ?? "pending" }}</strong>
<span>Last check {{ latestTimestamp }}</span>
</div>
<div class="health-runtime-list">
<article
v-for="(row, index) in targetRows"
:key="row?.target ?? `pending-${index}`"
class="health-runtime-card"
:class="row ? (row.healthy ? 'healthy' : 'unhealthy') : 'pending'"
>
<div class="health-runtime-card-head">
<strong>{{ row?.target ?? "pending" }}</strong>
<span>{{ row ? (row.healthy ? "ok" : "issue") : "waiting" }}</span>
</div>
<p class="muted small compact-copy">
{{ row ? `${row.host}:${row.port} · ${row.protocol}` : "No data yet." }}
</p>
<p
v-if="benchmarkEnabled && typeof row?.duration_ms === 'number'"
class="muted small compact-copy"
>
Last response {{ formatDuration(row.duration_ms) }}
</p>
<p v-if="row?.error" class="error compact health-inline-error">
{{ row.error }}
</p>
</article>
</div>
<div class="health-chart-card">
<div class="panel-title">
<h3>Recent timeline</h3>
<span class="muted small">{{ plane.healthHistory.length }} samples</span>
</div>
<div v-if="plane.healthHistory.length" class="health-chart-grid">
<div class="health-chart-row">
<span>Proxy</span>
<svg
class="health-chart"
:viewBox="`0 0 ${chartPoints.width} ${chartPoints.height}`"
preserveAspectRatio="none"
>
<polyline class="health-chart-line proxy" :points="chartPoints.proxy" />
</svg>
</div>
<div class="health-chart-row">
<span>Upstream</span>
<svg
class="health-chart"
:viewBox="`0 0 ${chartPoints.width} ${chartPoints.height}`"
preserveAspectRatio="none"
>
<polyline class="health-chart-line upstream" :points="chartPoints.upstream" />
</svg>
</div>
</div>
<p v-else class="muted small">
Health history will appear after the first scheduled runs.
</p>
</div>
<div v-if="benchmarkEnabled" class="health-chart-card">
<div class="panel-title">
<h3>Benchmark</h3>
<span class="muted small">Response latency</span>
</div>
<div class="benchmark-summary-grid">
<article class="metric-card">
<span class="metric-label">Proxy last</span>
<strong>{{ formatDuration(benchmarkSummary.proxyLast) }}</strong>
</article>
<article class="metric-card">
<span class="metric-label">Proxy avg</span>
<strong>{{ formatDuration(benchmarkSummary.proxyAvg) }}</strong>
</article>
<article class="metric-card">
<span class="metric-label">Upstream last</span>
<strong>{{ formatDuration(benchmarkSummary.upstreamLast) }}</strong>
</article>
<article class="metric-card">
<span class="metric-label">Upstream avg</span>
<strong>{{ formatDuration(benchmarkSummary.upstreamAvg) }}</strong>
</article>
</div>
<div v-if="benchmarkChart.hasData" class="health-chart-grid">
<div class="health-chart-row">
<span>Proxy</span>
<svg
class="health-chart benchmark-chart"
:viewBox="`0 0 ${benchmarkChart.width} ${benchmarkChart.height}`"
preserveAspectRatio="none"
>
<polyline class="health-chart-line proxy" :points="benchmarkChart.proxy" />
</svg>
</div>
<div class="health-chart-row">
<span>Upstream</span>
<svg
class="health-chart benchmark-chart"
:viewBox="`0 0 ${benchmarkChart.width} ${benchmarkChart.height}`"
preserveAspectRatio="none"
>
<polyline class="health-chart-line upstream" :points="benchmarkChart.upstream" />
</svg>
</div>
<p class="muted small compact-copy">
Scale 0 → {{ benchmarkChart.maxDuration.toFixed(2) }} ms
</p>
</div>
<p v-else class="muted small">
Collecting benchmark data from scheduled health runs.
</p>
</div>
</div>
</section>
</div>
</template>
+8
View File
@@ -40,6 +40,10 @@ const plane = useControlPlaneContext();
<span class="metric-label">Timeout</span>
<strong>{{ plane.draft.healthcheck.timeout_seconds }}s</strong>
</article>
<article class="metric-card">
<span class="metric-label">Benchmark</span>
<strong>{{ plane.draft.healthcheck.benchmark_enabled ? "Enabled" : "Disabled" }}</strong>
</article>
</div>
<div class="form-grid tight-grid">
@@ -98,6 +102,10 @@ const plane = useControlPlaneContext();
<input v-model="plane.draft.healthcheck.verify_tls" type="checkbox" />
<span>Verify TLS</span>
</label>
<label class="checkbox-row">
<input v-model="plane.draft.healthcheck.benchmark_enabled" type="checkbox" />
<span>Benchmark response time</span>
</label>
</div>
<div v-if="plane.health" class="health-box" :class="plane.health.overall">
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { computed } from "vue";
import { useControlPlaneContext } from "../composables/useControlPlane";
import UiIcon from "./UiIcon.vue";
const plane = useControlPlaneContext();
const latestTimestamp = computed(() => {
if (!plane.health?.timestamp) return "No checks yet";
return new Date(plane.health.timestamp).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
});
const proxyCheck = computed(() =>
plane.health?.checks.find((check) => check.target === "proxy") ?? null,
);
const upstreamCheck = computed(() =>
plane.health?.checks.find((check) => check.target === "upstream") ?? null,
);
</script>
<template>
<section
v-if="plane.selectedProxy"
class="health-summary-card"
:class="plane.health?.overall ?? 'pending'"
>
<div class="health-summary-line">
<strong class="health-summary-overall">{{ plane.health?.overall ?? "pending" }}</strong>
<span class="health-summary-item">
<small>Proxy</small>
<strong>{{ proxyCheck ? (proxyCheck.healthy ? "ok" : "issue") : "waiting" }}</strong>
</span>
<span class="health-summary-item">
<small>Upstream</small>
<strong>
{{ upstreamCheck ? (upstreamCheck.healthy ? "ok" : "issue") : "waiting" }}
</strong>
</span>
<span class="health-summary-item health-summary-time">
<small>Last check</small>
<strong>{{ latestTimestamp }}</strong>
</span>
<div class="health-summary-actions">
<button
class="ghost button-icon"
:disabled="plane.loading"
@click="plane.checkHealth"
>
<UiIcon name="pulse" />
<span>Run</span>
</button>
<button class="ghost button-icon" @click="plane.openHealthModal">
<span>Details</span>
</button>
</div>
</div>
</section>
</template>
+31 -7
View File
@@ -73,7 +73,11 @@ function formatDefault(option?: MitmOption | null) {
</button>
</div>
</div>
<div class="option-browser-meta">
<div v-if="plane.optionsLoading" class="option-browser-meta">
<span class="option-count">...</span>
<p class="muted small">Loading mitmdump options…</p>
</div>
<div v-else class="option-browser-meta">
<span class="option-count">{{ plane.filteredOptions.length }}</span>
<p class="muted small">
{{
@@ -84,7 +88,10 @@ function formatDefault(option?: MitmOption | null) {
</p>
</div>
<div class="option-table">
<div v-if="plane.optionsLoading" class="option-empty-state">
<p class="muted small">The option catalog is still loading.</p>
</div>
<div v-else-if="plane.filteredOptions.length" class="option-table">
<button
v-for="option in plane.filteredOptions"
:key="option.name"
@@ -96,10 +103,16 @@ function formatDefault(option?: MitmOption | null) {
<small>{{ option.type }}</small>
</button>
</div>
<div v-else class="option-empty-state">
<p class="muted small">No options matched the current filter.</p>
</div>
</aside>
<div class="option-step-main">
<article v-if="plane.selectedOption" class="option-doc option-doc-embedded">
<article
v-if="!plane.optionsLoading && plane.selectedOption"
class="option-doc option-doc-embedded"
>
<div class="option-doc-head">
<div>
<h3>{{ plane.selectedOption.name }}</h3>
@@ -254,9 +267,14 @@ function formatDefault(option?: MitmOption | null) {
<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>
<span class="option-count">
{{ plane.optionsLoading ? "..." : plane.filteredOptions.length }}
</span>
</div>
<p class="muted small compact-copy">
<p v-if="plane.optionsLoading" class="muted small compact-copy">
Loading mitmdump options…
</p>
<p v-else class="muted small compact-copy">
{{
plane.optionScope === "focused"
? "Showing the options that are usually useful in this UI."
@@ -264,7 +282,10 @@ function formatDefault(option?: MitmOption | null) {
}}
</p>
<div class="option-table">
<div v-if="plane.optionsLoading" class="option-empty-state">
<p class="muted small">The option catalog is still loading.</p>
</div>
<div v-else-if="plane.filteredOptions.length" class="option-table">
<button
v-for="option in plane.filteredOptions"
:key="option.name"
@@ -276,9 +297,12 @@ function formatDefault(option?: MitmOption | null) {
<small>{{ option.type }}</small>
</button>
</div>
<div v-else class="option-empty-state">
<p class="muted small">No options matched the current filter.</p>
</div>
</div>
<article v-if="plane.selectedOption" class="option-doc">
<article v-if="!plane.optionsLoading && plane.selectedOption" class="option-doc">
<div class="option-doc-head">
<div>
<h3>{{ plane.selectedOption.name }}</h3>
+5 -1
View File
@@ -19,7 +19,7 @@ const plane = useControlPlaneContext();
</p>
</div>
<div class="modal-head-actions">
<button class="primary button-icon" @click="plane.saveProxy">
<button class="primary button-icon" :disabled="!plane.canSaveProxy" @click="plane.saveProxy">
<UiIcon name="save" />
<span>Save & close</span>
</button>
@@ -30,6 +30,10 @@ const plane = useControlPlaneContext();
</div>
</header>
<p v-if="plane.wizardError" class="error modal-error">
{{ plane.wizardError }}
</p>
<div class="modal-stepper">
<button
v-for="(step, index) in ['Proxy route', 'Healthchecks', 'Custom mitmdump options']"
+12 -8
View File
@@ -1,11 +1,13 @@
<script setup lang="ts">
import { useControlPlaneContext } from "../composables/useControlPlane";
import EditorPane from "./EditorPane.vue";
import ExamplesPanel from "./ExamplesPanel.vue";
import HealthDetailsModal from "./HealthDetailsModal.vue";
import HealthSummaryBar from "./HealthSummaryBar.vue";
import LogsPanel from "./LogsPanel.vue";
import ProxyWizardModal from "./ProxyWizardModal.vue";
import ServiceDiscoveryPanel from "./ServiceDiscoveryPanel.vue";
import StatusPill from "./StatusPill.vue";
import TemplatesPanel from "./TemplatesPanel.vue";
import UiIcon from "./UiIcon.vue";
const plane = useControlPlaneContext();
@@ -17,9 +19,8 @@ const plane = useControlPlaneContext();
<div class="workbench-copy">
<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>
<span class="route-token">{{ plane.routeState.proxySummary }}</span>
<span class="route-target">{{ plane.routeState.upstreamSummary }}</span>
<StatusPill
:status="plane.selectedProxy.status"
/>
@@ -27,14 +28,16 @@ const plane = useControlPlaneContext();
<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 v-if="plane.selectedProxy && plane.routeState.proxyPortReservedMessage" class="error compact">
{{ plane.routeState.proxyPortReservedMessage }}
</p>
<p v-if="plane.selectedProxy?.last_error" class="error compact">
{{ plane.selectedProxy.last_error }}
</p>
</div>
<HealthSummaryBar />
<div class="action-row">
<button
v-if="plane.selectedProxy"
@@ -84,7 +87,7 @@ const plane = useControlPlaneContext();
</div>
</header>
<p v-if="plane.error" class="error">{{ plane.error }}</p>
<p v-if="plane.workspaceError" class="error">{{ plane.workspaceError }}</p>
<ServiceDiscoveryPanel v-if="!plane.selectedProxy && !plane.wizardOpen" />
@@ -98,9 +101,10 @@ const plane = useControlPlaneContext();
</div>
<div v-if="plane.selectedProxy" class="workspace-templates-row">
<ExamplesPanel />
<TemplatesPanel />
</div>
<ProxyWizardModal />
<HealthDetailsModal />
</section>
</template>
+5 -5
View File
@@ -16,11 +16,8 @@ const plane = useControlPlaneContext();
</div>
<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>
<span class="route-chip">{{ plane.routeState.proxySummary }}</span>
<span class="route-chip">{{ plane.routeState.upstreamSummary }}</span>
</div>
<div class="route-overview-grid">
@@ -52,6 +49,9 @@ const plane = useControlPlaneContext();
<small v-if="plane.selectedProxy">
Port is fixed for existing proxies. Create a new proxy to change it.
</small>
<small v-if="plane.routeState.proxyPortReservedMessage" class="route-port-error">
{{ plane.routeState.proxyPortReservedMessage }}
</small>
</label>
<label>
Upstream host
@@ -30,6 +30,9 @@ const plane = useControlPlaneContext();
<p v-if="plane.servicesLoading" class="muted small compact-copy">
Scanning compose folders in {{ plane.scanRoot }}...
</p>
<p v-if="plane.discoveryError" class="error compact-copy">
{{ plane.discoveryError }}
</p>
<p v-else-if="!plane.serviceEntries.length" class="muted small compact-copy">
No compose services detected in the scan root.
@@ -37,42 +40,59 @@ const plane = useControlPlaneContext();
<div v-else class="service-discovery-list">
<article
v-for="entry in plane.serviceEntries"
:key="entry.id"
class="service-discovery-row"
v-for="group in plane.serviceGroups"
:key="group.id"
class="service-group-card"
>
<div class="service-discovery-main">
<strong>{{ entry.service.name }}</strong>
<span class="service-route-copy">
{{ entry.listenHost }}:{{ entry.binding.published }}
<div class="service-group-head">
<div class="service-group-copy">
<h3>{{ group.title }}</h3>
<small class="service-folder">{{ group.folder }}</small>
</div>
<span class="muted small">
{{ group.entries.length }} {{ group.entries.length === 1 ? "service" : "services" }}
</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)"
<div class="service-group-list">
<article
v-for="entry in group.entries"
:key="entry.id"
class="service-discovery-row"
>
<UiIcon name="plus" />
<span>Create proxy</span>
</button>
<div class="service-discovery-main">
<strong>{{ entry.service.name }}</strong>
<span class="service-route-copy">
{{ entry.listenHost }}:{{ entry.binding.published }}
</span>
</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>
</article>
</div>
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { computed } from "vue";
const props = defineProps<{
code: string;
}>();
function escapeHtml(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
function highlightPython(source: string) {
const lines = source.split("\n");
return lines
.map((line) => {
const escaped = escapeHtml(line);
const commentIndex = escaped.indexOf("#");
const codePart = commentIndex >= 0 ? escaped.slice(0, commentIndex) : escaped;
const commentPart = commentIndex >= 0 ? escaped.slice(commentIndex) : "";
let highlighted = codePart
.replace(
/("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/g,
'<span class="py-string">$1</span>',
)
.replace(
/\b(from|import|def|return|if|else|elif|for|in|while|try|except|finally|pass|continue|break|as|None|True|False|and|or|not)\b/g,
'<span class="py-keyword">$1</span>',
)
.replace(
/\b([A-Z_][A-Z0-9_]*)\b/g,
'<span class="py-constant">$1</span>',
)
.replace(
/\b(logging|flow|http|tcp|dns)\b/g,
'<span class="py-name">$1</span>',
);
if (commentPart) {
highlighted += `<span class="py-comment">${commentPart}</span>`;
}
return highlighted;
})
.join("\n");
}
const highlightedCode = computed(() => highlightPython(props.code));
</script>
<template>
<pre class="terminal template-detail-code template-code-preview"><code v-html="highlightedCode" /></pre>
</template>
@@ -0,0 +1,96 @@
<script setup lang="ts">
import { useControlPlaneContext } from "../composables/useControlPlane";
import TemplateCodePreview from "./TemplateCodePreview.vue";
const plane = useControlPlaneContext();
</script>
<template>
<section class="panel stack-panel tool-panel templates-panel">
<div class="panel-title">
<h2>Templates</h2>
</div>
<div class="template-savebar">
<input
v-model="plane.customTemplateName"
placeholder="Save current script as template..."
/>
<button @click="plane.saveCurrentAsTemplate">Save</button>
</div>
<p v-if="plane.templatesLoading" class="muted small compact-copy">Refreshing templates...</p>
<div class="template-layout">
<aside class="template-sidebar">
<div class="template-sidebar-controls">
<input v-model="plane.templateSearch" placeholder="Search templates" />
<div class="template-protocol-tabs">
<button
class="ghost"
:class="{ active: plane.templateProtocolFilter === 'http' }"
@click="plane.templateProtocolFilter = 'http'"
>
HTTP
</button>
<button
class="ghost"
:class="{ active: plane.templateProtocolFilter === 'tcp' }"
@click="plane.templateProtocolFilter = 'tcp'"
>
TCP
</button>
</div>
</div>
<div class="template-list">
<button
v-for="template in plane.filteredTemplates"
:key="template.id"
class="template-list-item"
:class="{ selected: plane.selectedTemplate?.id === template.id }"
@click="plane.selectTemplate(template.id)"
>
<strong>{{ template.name }}</strong>
<span class="template-list-summary">
{{ template.behavior || template.summary || template.description }}
</span>
</button>
</div>
</aside>
<article v-if="plane.selectedTemplate" class="template-detail">
<div class="template-detail-head">
<div class="template-detail-copy">
<h3>{{ plane.selectedTemplate.name }}</h3>
<p class="doc-copy compact-copy">
{{
plane.selectedTemplate.behavior ||
plane.selectedTemplate.summary ||
plane.selectedTemplate.description
}}
</p>
</div>
</div>
<TemplateCodePreview :code="plane.selectedTemplate.code" />
<div class="doc-actions">
<button @click="plane.applyTemplate(plane.selectedTemplate)">Use template</button>
<button
v-if="!plane.selectedTemplate.built_in"
class="ghost"
@click="plane.removeTemplate(plane.selectedTemplate.id)"
>
Delete
</button>
</div>
</article>
<article v-else class="template-detail template-detail-empty">
<p class="muted">No template available for this protocol.</p>
</article>
</div>
</section>
</template>
+9
View File
@@ -12,6 +12,7 @@ defineProps<{
| "terminal"
| "link"
| "spark"
| "pulse"
| "close"
| "back"
| "next"
@@ -113,6 +114,14 @@ defineProps<{
<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 === 'pulse'"
viewBox="0 0 16 16"
aria-hidden="true"
class="ui-icon"
>
<path d="M2.5 8h2.2l1.2-2.7 2.2 5.4 1.6-3.2h3.8" />
</svg>
<svg
v-else-if="name === 'close'"
viewBox="0 0 16 16"
@@ -0,0 +1,148 @@
import type {
DockerService,
DockerServiceBinding,
ProxyDocument,
ProxyDraft,
} from "../types";
export const modes = [
"reverse",
"regular",
"socks5",
"upstream",
"wireguard",
"transparent",
"local",
"tun",
"dns",
];
export const protocols = [
"http",
"https",
"tcp",
"tls",
"udp",
"dtls",
"dns",
"http3",
"quic",
];
export function createDefaultDraft(port = 8080, code?: string): ProxyDraft {
return {
proxy_port: port,
name: "new-proxy",
code:
code ??
`from mitmproxy import http
def request(flow: http.HTTPFlow) -> None:
pass
`,
mode: "reverse",
mode_spec: null,
protocol: "http",
listen_host: "0.0.0.0",
mitm_options: {
flow_detail: 1,
termlog_verbosity: "info",
},
healthcheck: {
enabled: true,
protocol: null,
path: "/",
method: "GET",
expected_status: null,
udp_payload: "",
expect_response: true,
verify_tls: false,
benchmark_enabled: false,
timeout_seconds: 2,
},
service: {
host: "127.0.0.1",
port: 9000,
bind_host: null,
name: null,
folder: null,
upstream_port: null,
},
};
}
export function folderBasename(folder: string | null | undefined): string {
if (!folder) return "";
const normalized = folder.replace(/\\/g, "/").replace(/\/+$/, "");
const parts = normalized.split("/");
return parts[parts.length - 1] ?? "";
}
export function slugify(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
export function bindingsFor(service: DockerService) {
return service.bindings?.length
? service.bindings
: service.ports.map((published) => ({ published, host: null }));
}
export function draftPayload(value: ProxyDraft | ProxyDocument): ProxyDraft {
return JSON.parse(
JSON.stringify({
proxy_port: value.proxy_port,
name: value.name,
code: value.code,
mode: value.mode,
mode_spec: value.mode_spec || null,
protocol: value.protocol,
listen_host: value.listen_host,
mitm_options: value.mitm_options,
healthcheck: {
...value.healthcheck,
expected_status: value.healthcheck.expected_status || null,
protocol: value.healthcheck.protocol || null,
},
service: {
host: value.service.host,
port: value.service.port,
bind_host: value.service.bind_host || null,
name: value.service.name || null,
folder: value.service.folder || null,
upstream_port: value.service.upstream_port || null,
},
}),
) as ProxyDraft;
}
export function serializeDraft(value: ProxyDraft | ProxyDocument) {
return JSON.stringify(draftPayload(value));
}
export function draftForService(
service: DockerService,
binding: DockerServiceBinding,
templateCode?: string,
): ProxyDraft {
const template = createDefaultDraft(binding.published, templateCode);
const folderSlug = slugify(folderBasename(service.folder));
const portSuffix = bindingsFor(service).length > 1 ? `-${binding.published}` : "";
return {
...template,
name: `${folderSlug ? `${folderSlug}-` : ""}${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;
}
File diff suppressed because it is too large Load Diff
+403 -30
View File
@@ -442,13 +442,15 @@ pre {
.workbench-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 1rem;
align-items: center;
}
.workbench-copy {
display: grid;
gap: 0.7rem;
min-width: 0;
}
.route-row {
@@ -574,7 +576,7 @@ pre {
.workspace-main-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
gap: 1rem;
align-items: start;
}
@@ -586,7 +588,8 @@ pre {
}
.workspace-editor-pane,
.workspace-logs-pane {
.workspace-logs-pane,
.workspace-templates-row {
display: grid;
}
@@ -645,12 +648,11 @@ pre {
.service-folder {
display: block;
overflow: hidden;
color: var(--muted);
font-family: "IBM Plex Mono", monospace;
font-size: 0.74rem;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.45;
overflow-wrap: anywhere;
}
.compact-details {
@@ -711,6 +713,205 @@ pre {
font-size: 0.8rem;
}
.health-runtime-overview {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.8rem 0.9rem;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: #0d1116;
}
.health-runtime-overview strong,
.health-runtime-card-head strong {
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.78rem;
}
.health-runtime-overview.healthy {
border-color: #2a5437;
color: #b8f3c9;
}
.health-runtime-overview.unhealthy {
border-color: #5f2d2d;
color: #ffd7d7;
}
.health-runtime-overview.disabled,
.health-runtime-overview.pending {
color: var(--muted);
}
.health-runtime-list {
display: grid;
gap: 0.75rem;
}
.health-runtime-card {
display: grid;
gap: 0.45rem;
padding: 0.8rem 0.9rem;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--bg-elevated);
}
.health-runtime-card.healthy {
border-color: #2a5437;
}
.health-runtime-card.unhealthy {
border-color: #5f2d2d;
}
.health-runtime-card.pending {
color: var(--muted);
}
.health-runtime-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.health-inline-error {
width: auto;
}
.health-chart-card {
display: grid;
gap: 0.75rem;
padding: 0.85rem;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--bg-elevated);
}
.health-chart-card h3 {
margin: 0;
font-size: 0.96rem;
}
.benchmark-summary-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.health-chart-grid {
display: grid;
gap: 0.65rem;
}
.health-chart-row {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
gap: 0.75rem;
align-items: center;
}
.health-chart-row span {
color: var(--muted);
font-family: "IBM Plex Mono", monospace;
font-size: 0.78rem;
}
.health-chart {
width: 100%;
height: 56px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: #090c0f;
}
.benchmark-chart {
height: 72px;
}
.health-chart-line {
fill: none;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.health-chart-line.proxy {
stroke: #7dcfe7;
}
.health-chart-line.upstream {
stroke: #f5b55f;
}
.health-summary-card {
display: flex;
align-items: center;
padding: 0.55rem 0.7rem;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: #0d1116;
}
.health-summary-card.healthy {
border-color: #2a5437;
}
.health-summary-card.unhealthy {
border-color: #5f2d2d;
}
.health-summary-line {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
}
.health-summary-overall,
.health-summary-item strong {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.health-summary-actions {
display: flex;
align-items: center;
gap: 0.55rem;
margin-left: auto;
}
.health-summary-item {
display: inline-grid;
gap: 0.08rem;
}
.health-summary-item small {
color: var(--muted);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.health-summary-time strong {
text-transform: none;
letter-spacing: 0;
}
.health-modal-shell {
width: min(760px, calc(100vw - 2rem));
}
.health-modal-body {
display: grid;
gap: 0.9rem;
}
.title-actions,
.segmented {
display: flex;
@@ -794,14 +995,137 @@ pre {
text-decoration: underline;
}
.option-shell,
.example-shell {
.option-shell {
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
gap: 0.9rem;
min-height: 0;
}
.templates-panel {
min-width: 0;
}
.template-savebar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem;
}
.template-layout {
display: grid;
grid-template-columns: 500px minmax(0, 1fr);
gap: 1rem;
min-height: 0;
}
.template-sidebar,
.template-detail {
display: grid;
align-content: start;
gap: 0.9rem;
padding: 0.95rem;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--bg-elevated);
min-width: 0;
}
.template-sidebar-controls,
.template-detail-sections {
display: grid;
gap: 0.75rem;
}
.template-protocol-tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.5rem;
}
.template-protocol-tabs button.active {
border-color: #7dcfe7;
color: #dff8ff;
background: #18232b;
}
.template-sidebar-meta {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
}
.template-list {
display: grid;
gap: 0.55rem;
max-height: 620px;
overflow: auto;
}
.template-list-item {
width: 100%;
display: grid;
gap: 0.35rem;
padding: 0.75rem;
text-align: left;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: #0d1116;
}
.template-list-item.selected {
border-color: #496171;
background: #131920;
}
.template-list-summary {
color: var(--muted);
line-height: 1.45;
}
.template-detail-copy h3 {
margin: 0;
}
.template-detail-copy {
display: grid;
gap: 0.55rem;
}
.template-detail-code {
min-height: 360px;
max-height: 520px;
}
.template-code-preview {
white-space: pre;
}
.template-code-preview .py-keyword {
color: #d88dff;
}
.template-code-preview .py-string {
color: #9ed27c;
}
.template-code-preview .py-comment {
color: #687a88;
}
.template-code-preview .py-constant {
color: #e6c16d;
}
.template-code-preview .py-name {
color: #79c7f0;
}
.template-detail-empty {
align-items: center;
}
.option-step-layout {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
@@ -816,9 +1140,7 @@ pre {
}
.option-browser,
.example-browser,
.option-doc,
.example-preview {
.option-doc {
display: grid;
align-content: start;
gap: 0.75rem;
@@ -852,6 +1174,10 @@ pre {
margin: 0;
}
.option-empty-state {
padding: 0.4rem 0;
}
.option-active-card {
display: grid;
gap: 0.75rem;
@@ -889,16 +1215,14 @@ pre {
font-size: 0.76rem;
}
.option-table,
.example-index {
.option-table {
display: grid;
gap: 0.45rem;
max-height: 320px;
overflow: auto;
}
.option-entry,
.example-entry {
.option-entry {
width: 100%;
display: grid;
gap: 0.2rem;
@@ -907,8 +1231,7 @@ pre {
background: #0d1116;
}
.option-entry.selected,
.example-entry.selected {
.option-entry.selected {
border-color: #496171;
background: #131920;
}
@@ -945,12 +1268,6 @@ pre {
max-width: 70ch;
}
.example-snippet {
color: var(--muted);
font-size: 0.78rem;
line-height: 1.45;
}
.doc-facts,
.template-meta {
display: grid;
@@ -1020,6 +1337,9 @@ pre {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 0.85rem;
height: calc(clamp(420px, 68vh, 760px) + 72px);
max-height: calc(clamp(420px, 68vh, 760px) + 72px);
overflow: hidden;
}
.editor-meta {
@@ -1055,8 +1375,8 @@ pre {
}
.editor-shell {
height: clamp(420px, 68vh, 760px);
max-height: clamp(420px, 68vh, 760px);
height: 100%;
max-height: 100%;
min-height: 0;
border: 1px solid var(--line);
border-radius: var(--radius-md);
@@ -1081,8 +1401,8 @@ pre {
.logs-panel {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
height: clamp(420px, 68vh, 760px);
max-height: clamp(420px, 68vh, 760px);
height: calc(clamp(420px, 68vh, 760px) + 72px);
max-height: calc(clamp(420px, 68vh, 760px) + 72px);
overflow: hidden;
}
@@ -1134,6 +1454,11 @@ pre {
font-size: 0.81rem;
}
.route-port-error {
color: #ffb4b4;
line-height: 1.35;
}
.route-form-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -1147,6 +1472,38 @@ pre {
}
.service-discovery-list {
display: grid;
gap: 0.9rem;
}
.service-group-card {
display: grid;
gap: 0.9rem;
padding: 0.95rem;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--bg-elevated);
}
.service-group-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.service-group-copy {
display: grid;
gap: 0.2rem;
min-width: 0;
}
.service-group-copy h3 {
margin: 0;
font-size: 1rem;
}
.service-group-list {
display: grid;
gap: 0.65rem;
}
@@ -1159,7 +1516,7 @@ pre {
padding: 0.8rem 0.85rem;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--bg-elevated);
background: #0d1116;
}
.service-discovery-main {
@@ -1230,6 +1587,10 @@ pre {
color: var(--muted);
}
.modal-error {
margin: 0;
}
.modal-stepper {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -1317,7 +1678,7 @@ pre {
}
.option-shell,
.example-shell {
.template-layout {
grid-template-columns: 1fr;
}
}
@@ -1366,13 +1727,21 @@ pre {
grid-template-columns: 1fr;
}
.template-savebar {
grid-template-columns: 1fr;
}
.health-summary-actions {
margin-left: 0;
}
.editor-shell,
.code-editor-wrap,
.code-editor {
min-height: 0;
}
.editor-shell,
.editor-pane,
.logs-panel {
height: 500px;
max-height: 500px;
@@ -1422,4 +1791,8 @@ pre {
.service-discovery-actions {
justify-content: flex-start;
}
.service-group-head {
display: grid;
}
}
+33 -1
View File
@@ -21,6 +21,7 @@ export type HealthCheck = {
udp_payload: string;
expect_response: boolean;
verify_tls: boolean;
benchmark_enabled: boolean;
timeout_seconds: number;
};
@@ -55,6 +56,7 @@ export type DockerService = {
export type ServiceDiscoveryResponse = {
scan_root: string;
services: DockerService[];
backend_port: number;
};
export type MitmOption = {
@@ -67,17 +69,40 @@ export type MitmOption = {
docs_url?: string | null;
};
export type AddonExample = {
export type ProxyTemplate = {
id: string;
name: string;
description: string;
category: string;
protocol: string;
family: string;
tags: string[];
action: string;
summary: string;
behavior: string;
code: string;
source_url?: string | null;
built_in: boolean;
};
export type ProxyTemplateCreateInput = {
id: string;
name: string;
description: string;
category: string;
protocol: string;
family: string;
tags: string[];
action: string;
summary: string;
behavior: string;
code: string;
source_url?: string | null;
};
export type HealthResult = {
proxy_port: number;
timestamp: string;
enabled: boolean;
overall: string;
checks: Array<{
@@ -87,10 +112,17 @@ export type HealthResult = {
protocol: string;
healthy: boolean;
error?: string | null;
duration_ms?: number | null;
details: Record<string, unknown>;
}>;
};
export type HealthHistoryResponse = {
proxy_port: number;
latest: HealthResult | null;
history: HealthResult[];
};
export type PythonEditorSession = {
root_uri: string;
file_uri: string;
+1 -1
View File
@@ -10,7 +10,7 @@ export default defineConfig({
port: 5173,
proxy: {
"/api": {
target: "http://127.0.0.1:3871",
target: "http://127.0.0.1:3671",
changeOrigin: true,
ws: true,
rewrite: (path) => path.replace(/^\/api/, ""),