feat: troppa roba davvero
This commit is contained in:
@@ -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
@@ -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`),
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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']"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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/, ""),
|
||||
|
||||
Reference in New Issue
Block a user