feat: add proxy template origin field and implement socket utilities for log streaming
This commit is contained in:
@@ -5,8 +5,13 @@ import { useControlPlaneContext } from "../composables/useControlPlane";
|
||||
import UiIcon from "./UiIcon.vue";
|
||||
|
||||
const plane = useControlPlaneContext();
|
||||
const monitoringActive = computed(() => plane.runtimeStatus === "running");
|
||||
const overallLabel = computed(() =>
|
||||
monitoringActive.value ? plane.health?.overall ?? "pending" : "stopped",
|
||||
);
|
||||
|
||||
const latestTimestamp = computed(() => {
|
||||
if (!monitoringActive.value) return "Monitoring inactive";
|
||||
if (!plane.health?.timestamp) return "No checks yet";
|
||||
return new Date(plane.health.timestamp).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
@@ -16,6 +21,9 @@ const latestTimestamp = computed(() => {
|
||||
});
|
||||
|
||||
const targetRows = computed(() => {
|
||||
if (!monitoringActive.value) {
|
||||
return [null, null];
|
||||
}
|
||||
const checks = plane.health?.checks ?? [];
|
||||
return [
|
||||
checks.find((check) => check.target === "proxy") ?? null,
|
||||
@@ -135,7 +143,11 @@ function formatDuration(value: number | null) {
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-head-actions">
|
||||
<button class="ghost button-icon" :disabled="plane.loading" @click="plane.checkHealth">
|
||||
<button
|
||||
class="ghost button-icon"
|
||||
:disabled="plane.loading || !monitoringActive"
|
||||
@click="plane.checkHealth"
|
||||
>
|
||||
<UiIcon name="pulse" />
|
||||
<span>Run now</span>
|
||||
</button>
|
||||
@@ -149,9 +161,9 @@ function formatDuration(value: number | null) {
|
||||
<div class="modal-body health-modal-body">
|
||||
<div
|
||||
class="health-runtime-overview"
|
||||
:class="plane.health?.overall ?? 'disabled'"
|
||||
:class="overallLabel"
|
||||
>
|
||||
<strong>{{ plane.health?.overall ?? "pending" }}</strong>
|
||||
<strong>{{ overallLabel }}</strong>
|
||||
<span>Last check {{ latestTimestamp }}</span>
|
||||
</div>
|
||||
|
||||
@@ -160,14 +172,38 @@ function formatDuration(value: number | null) {
|
||||
v-for="(row, index) in targetRows"
|
||||
:key="row?.target ?? `pending-${index}`"
|
||||
class="health-runtime-card"
|
||||
:class="row ? (row.healthy ? 'healthy' : 'unhealthy') : 'pending'"
|
||||
:class="
|
||||
row
|
||||
? row.healthy
|
||||
? 'healthy'
|
||||
: 'unhealthy'
|
||||
: monitoringActive
|
||||
? 'pending'
|
||||
: 'inactive'
|
||||
"
|
||||
>
|
||||
<div class="health-runtime-card-head">
|
||||
<strong>{{ row?.target ?? "pending" }}</strong>
|
||||
<span>{{ row ? (row.healthy ? "ok" : "issue") : "waiting" }}</span>
|
||||
<strong>{{
|
||||
row?.target ?? (index === 0 ? "proxy" : "upstream")
|
||||
}}</strong>
|
||||
<span>{{
|
||||
row
|
||||
? row.healthy
|
||||
? "ok"
|
||||
: "issue"
|
||||
: monitoringActive
|
||||
? "pending"
|
||||
: "inactive"
|
||||
}}</span>
|
||||
</div>
|
||||
<p class="muted small compact-copy">
|
||||
{{ row ? `${row.host}:${row.port} · ${row.protocol}` : "No data yet." }}
|
||||
{{
|
||||
row
|
||||
? `${row.host}:${row.port} · ${row.protocol}`
|
||||
: monitoringActive
|
||||
? "No data yet."
|
||||
: "Start the proxy to enable active checks."
|
||||
}}
|
||||
</p>
|
||||
<p
|
||||
v-if="benchmarkEnabled && typeof row?.duration_ms === 'number'"
|
||||
@@ -210,7 +246,11 @@ function formatDuration(value: number | null) {
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="muted small">
|
||||
Health history will appear after the first scheduled runs.
|
||||
{{
|
||||
monitoringActive
|
||||
? "Health history will appear after the first scheduled runs."
|
||||
: "Health history starts once the proxy is running."
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,8 +5,13 @@ import { useControlPlaneContext } from "../composables/useControlPlane";
|
||||
import UiIcon from "./UiIcon.vue";
|
||||
|
||||
const plane = useControlPlaneContext();
|
||||
const monitoringActive = computed(() => plane.runtimeStatus === "running");
|
||||
const overallLabel = computed(() =>
|
||||
monitoringActive.value ? plane.health?.overall ?? "pending" : "stopped",
|
||||
);
|
||||
|
||||
const latestTimestamp = computed(() => {
|
||||
if (!monitoringActive.value) return "Monitoring inactive";
|
||||
if (!plane.health?.timestamp) return "No checks yet";
|
||||
return new Date(plane.health.timestamp).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
@@ -21,25 +26,35 @@ const proxyCheck = computed(() =>
|
||||
const upstreamCheck = computed(() =>
|
||||
plane.health?.checks.find((check) => check.target === "upstream") ?? null,
|
||||
);
|
||||
|
||||
const proxyLabel = computed(() => {
|
||||
if (!monitoringActive.value) return "inactive";
|
||||
return proxyCheck.value ? (proxyCheck.value.healthy ? "ok" : "issue") : "pending";
|
||||
});
|
||||
|
||||
const upstreamLabel = computed(() => {
|
||||
if (!monitoringActive.value) return "inactive";
|
||||
return upstreamCheck.value
|
||||
? (upstreamCheck.value.healthy ? "ok" : "issue")
|
||||
: "pending";
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
v-if="plane.selectedProxy"
|
||||
class="health-summary-card"
|
||||
:class="plane.health?.overall ?? 'pending'"
|
||||
:class="overallLabel"
|
||||
>
|
||||
<div class="health-summary-line">
|
||||
<strong class="health-summary-overall">{{ plane.health?.overall ?? "pending" }}</strong>
|
||||
<strong class="health-summary-overall">{{ overallLabel }}</strong>
|
||||
<span class="health-summary-item">
|
||||
<small>Proxy</small>
|
||||
<strong>{{ proxyCheck ? (proxyCheck.healthy ? "ok" : "issue") : "waiting" }}</strong>
|
||||
<strong>{{ proxyLabel }}</strong>
|
||||
</span>
|
||||
<span class="health-summary-item">
|
||||
<small>Upstream</small>
|
||||
<strong>
|
||||
{{ upstreamCheck ? (upstreamCheck.healthy ? "ok" : "issue") : "waiting" }}
|
||||
</strong>
|
||||
<strong>{{ upstreamLabel }}</strong>
|
||||
</span>
|
||||
<span class="health-summary-item health-summary-time">
|
||||
<small>Last check</small>
|
||||
@@ -48,7 +63,7 @@ const upstreamCheck = computed(() =>
|
||||
<div class="health-summary-actions">
|
||||
<button
|
||||
class="ghost button-icon"
|
||||
:disabled="plane.loading"
|
||||
:disabled="plane.loading || !monitoringActive"
|
||||
@click="plane.checkHealth"
|
||||
>
|
||||
<UiIcon name="pulse" />
|
||||
|
||||
@@ -79,7 +79,7 @@ const plane = useControlPlaneContext();
|
||||
<div class="doc-actions">
|
||||
<button @click="plane.applyTemplate(plane.selectedTemplate)">Use template</button>
|
||||
<button
|
||||
v-if="!plane.selectedTemplate.built_in"
|
||||
v-if="plane.selectedTemplate.origin === 'custom'"
|
||||
class="ghost"
|
||||
@click="plane.removeTemplate(plane.selectedTemplate.id)"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { computed, type Ref } from "vue";
|
||||
|
||||
import type { MitmOption, ProxyDraft, ProxyTemplate } from "../types";
|
||||
|
||||
export const focusedMitmOptions = new Set([
|
||||
"anticache",
|
||||
"block_global",
|
||||
"connection_strategy",
|
||||
"flow_detail",
|
||||
"http2",
|
||||
"http3",
|
||||
"keep_host_header",
|
||||
"rawtcp",
|
||||
"showhost",
|
||||
"ssl_insecure",
|
||||
"termlog_verbosity",
|
||||
"upstream_cert",
|
||||
"validate_inbound_headers",
|
||||
"websocket",
|
||||
]);
|
||||
|
||||
export type OptionScope = "focused" | "all";
|
||||
export type TemplateProtocolFilter = "http" | "tcp";
|
||||
|
||||
export function defaultFor(option: MitmOption) {
|
||||
if (option.default !== null && option.default !== undefined) return option.default;
|
||||
if (option.type === "bool") return false;
|
||||
if (option.type === "int" || option.type === "optional int") return 0;
|
||||
if (option.type === "sequence of str") return [];
|
||||
return "";
|
||||
}
|
||||
|
||||
export function templateProtocolForDraft(draft: ProxyDraft): "http" | "tcp" {
|
||||
return ["http", "https", "http3"].includes(draft.protocol) ? "http" : "tcp";
|
||||
}
|
||||
|
||||
type CatalogStateOptions = {
|
||||
options: Ref<MitmOption[]>;
|
||||
optionsLoading: Ref<boolean>;
|
||||
optionSearch: Ref<string>;
|
||||
optionScope: Ref<OptionScope>;
|
||||
selectedOptionName: Ref<string>;
|
||||
templates: Ref<ProxyTemplate[]>;
|
||||
templateSearch: Ref<string>;
|
||||
templateProtocolFilter: Ref<TemplateProtocolFilter>;
|
||||
selectedTemplateId: Ref<string>;
|
||||
draft: ProxyDraft;
|
||||
};
|
||||
|
||||
export function useControlPlaneCatalog({
|
||||
options,
|
||||
optionsLoading,
|
||||
optionSearch,
|
||||
optionScope,
|
||||
selectedOptionName,
|
||||
templates,
|
||||
templateSearch,
|
||||
templateProtocolFilter,
|
||||
selectedTemplateId,
|
||||
draft,
|
||||
}: CatalogStateOptions) {
|
||||
const filteredOptions = computed(() => {
|
||||
if (optionsLoading.value) return [];
|
||||
const query = optionSearch.value.toLowerCase().trim();
|
||||
const available = options.value.filter((option) => !option.reserved);
|
||||
const scoped = query
|
||||
? available
|
||||
: optionScope.value === "all"
|
||||
? available
|
||||
: available.filter((option) => focusedMitmOptions.has(option.name));
|
||||
const filtered = scoped
|
||||
.filter((option) => {
|
||||
if (!query) return true;
|
||||
return `${option.name} ${option.description}`.toLowerCase().includes(query);
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return filtered.slice(0, query ? 80 : optionScope.value === "all" ? 80 : 18);
|
||||
});
|
||||
|
||||
const optionCount = computed(
|
||||
() => options.value.filter((option) => !option.reserved).length,
|
||||
);
|
||||
const activeOptionCount = computed(() => Object.keys(draft.mitm_options).length);
|
||||
|
||||
const selectedOption = computed(
|
||||
() =>
|
||||
filteredOptions.value.find((option) => option.name === selectedOptionName.value) ??
|
||||
filteredOptions.value[0] ??
|
||||
null,
|
||||
);
|
||||
|
||||
const filteredTemplates = computed(() => {
|
||||
const query = templateSearch.value.toLowerCase().trim();
|
||||
const protocol = templateProtocolFilter.value;
|
||||
const filtered = templates.value.filter((template) => {
|
||||
if (template.protocol !== protocol) return false;
|
||||
if (!query) return true;
|
||||
return [
|
||||
template.name,
|
||||
template.description,
|
||||
template.summary,
|
||||
template.behavior,
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(query);
|
||||
});
|
||||
const sorted = filtered.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return query ? sorted : sorted.slice(0, 40);
|
||||
});
|
||||
|
||||
const selectedTemplate = computed(
|
||||
() =>
|
||||
filteredTemplates.value.find((template) => template.id === selectedTemplateId.value) ??
|
||||
filteredTemplates.value[0] ??
|
||||
null,
|
||||
);
|
||||
|
||||
const activeOptions = computed(() =>
|
||||
Object.entries(draft.mitm_options)
|
||||
.map(([name, value]) => ({
|
||||
name,
|
||||
value: Array.isArray(value) ? value.join(", ") : String(value ?? ""),
|
||||
option: options.value.find((item) => item.name === name),
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
);
|
||||
|
||||
return {
|
||||
activeOptionCount,
|
||||
activeOptions,
|
||||
filteredOptions,
|
||||
filteredTemplates,
|
||||
optionCount,
|
||||
selectedOption,
|
||||
selectedTemplate,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { computed, type Ref } from "vue";
|
||||
|
||||
import { bindingsFor, folderBasename } from "./controlPlaneDraft";
|
||||
import type { DockerService, ProxyDocument } from "../types";
|
||||
|
||||
export function useControlPlaneDiscovery(
|
||||
services: Ref<DockerService[]>,
|
||||
proxies: Ref<ProxyDocument[]>,
|
||||
) {
|
||||
const serviceEntries = computed(() =>
|
||||
services.value.flatMap((service) =>
|
||||
bindingsFor(service).map((binding) => {
|
||||
const proxy =
|
||||
proxies.value.find(
|
||||
(item) =>
|
||||
item.service.name === service.name &&
|
||||
item.service.folder === service.folder &&
|
||||
item.service.port === binding.published,
|
||||
) ?? null;
|
||||
|
||||
return {
|
||||
id: `${service.folder}:${service.name}:${binding.host ?? "*"}:${binding.published}`,
|
||||
service,
|
||||
binding,
|
||||
proxy,
|
||||
listenHost: binding.host ?? "0.0.0.0",
|
||||
};
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const missingServiceCount = computed(
|
||||
() => serviceEntries.value.filter((entry) => !entry.proxy).length,
|
||||
);
|
||||
|
||||
const serviceGroups = computed(() => {
|
||||
const groups = new Map<
|
||||
string,
|
||||
{
|
||||
id: string;
|
||||
title: string;
|
||||
folder: string;
|
||||
entries: (typeof serviceEntries.value)[number][];
|
||||
}
|
||||
>();
|
||||
|
||||
for (const entry of serviceEntries.value) {
|
||||
const folder = entry.service.folder ?? "";
|
||||
const existing = groups.get(folder);
|
||||
if (existing) {
|
||||
existing.entries.push(entry);
|
||||
continue;
|
||||
}
|
||||
|
||||
groups.set(folder, {
|
||||
id: folder || entry.service.name,
|
||||
title: folderBasename(folder) || "Compose folder",
|
||||
folder,
|
||||
entries: [entry],
|
||||
});
|
||||
}
|
||||
|
||||
return [...groups.values()]
|
||||
.map((group) => ({
|
||||
...group,
|
||||
entries: [...group.entries].sort((left, right) => {
|
||||
const byName = left.service.name.localeCompare(right.service.name);
|
||||
if (byName !== 0) return byName;
|
||||
return left.binding.published - right.binding.published;
|
||||
}),
|
||||
}))
|
||||
.sort((left, right) => left.title.localeCompare(right.title));
|
||||
});
|
||||
|
||||
return {
|
||||
missingServiceCount,
|
||||
serviceEntries,
|
||||
serviceGroups,
|
||||
};
|
||||
}
|
||||
@@ -40,6 +40,10 @@ export function createDefaultDraft(port = 8080, code?: string): ProxyDraft {
|
||||
|
||||
def request(flow: http.HTTPFlow) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def response(flow: http.HTTPFlow) -> None:
|
||||
pass
|
||||
`,
|
||||
mode: "reverse",
|
||||
mode_spec: null,
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { ref, watch, type Ref } from "vue";
|
||||
|
||||
import { api, buildSocketUrl } from "../api";
|
||||
import type { HealthResult } from "../types";
|
||||
|
||||
type HealthPlaneOptions = {
|
||||
token: Ref<string | null>;
|
||||
selectedPort: Ref<number | null>;
|
||||
onUnauthorized: () => void;
|
||||
onError: (error: unknown) => void;
|
||||
};
|
||||
|
||||
export function useControlPlaneHealth({
|
||||
token,
|
||||
selectedPort,
|
||||
onUnauthorized,
|
||||
onError,
|
||||
}: HealthPlaneOptions) {
|
||||
const health = ref<HealthResult | null>(null);
|
||||
const healthHistory = ref<HealthResult[]>([]);
|
||||
const healthModalOpen = ref(false);
|
||||
|
||||
let healthSocket: WebSocket | null = null;
|
||||
let healthReconnectTimer: number | null = null;
|
||||
|
||||
function parseHealthFrame(raw: string) {
|
||||
try {
|
||||
const payload = JSON.parse(raw) as HealthResult;
|
||||
if (
|
||||
typeof payload?.proxy_port === "number" &&
|
||||
typeof payload?.timestamp === "string" &&
|
||||
Array.isArray(payload?.checks)
|
||||
) {
|
||||
return payload;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pushHealthSample(snapshot: HealthResult) {
|
||||
const next = [...healthHistory.value];
|
||||
const index = next.findIndex((item) => item.timestamp === snapshot.timestamp);
|
||||
if (index >= 0) {
|
||||
next[index] = snapshot;
|
||||
} else {
|
||||
next.push(snapshot);
|
||||
}
|
||||
next.sort((left, right) => left.timestamp.localeCompare(right.timestamp));
|
||||
healthHistory.value = next.slice(-40);
|
||||
}
|
||||
|
||||
async function syncHealthState(silent = false) {
|
||||
const port = selectedPort.value;
|
||||
if (port === null) return;
|
||||
try {
|
||||
const response = await api.healthHistory(port);
|
||||
health.value = response.latest;
|
||||
healthHistory.value = response.history;
|
||||
} catch (error) {
|
||||
if (!silent) onError(error);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleHealthReconnect(proxyPort: number) {
|
||||
if (typeof window === "undefined" || healthReconnectTimer !== null) return;
|
||||
healthReconnectTimer = window.setTimeout(() => {
|
||||
healthReconnectTimer = null;
|
||||
if (!token.value || selectedPort.value !== proxyPort) return;
|
||||
void openHealthStream(proxyPort);
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
async function openHealthStream(proxyPort: number) {
|
||||
await syncHealthState(true);
|
||||
|
||||
if (
|
||||
typeof window === "undefined" ||
|
||||
!token.value ||
|
||||
selectedPort.value !== proxyPort
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const socket = new WebSocket(buildSocketUrl(`/proxy/${proxyPort}/health/ws`));
|
||||
healthSocket = socket;
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
if (socket !== healthSocket) return;
|
||||
const snapshot = parseHealthFrame(event.data);
|
||||
if (snapshot === null) return;
|
||||
health.value = snapshot;
|
||||
pushHealthSample(snapshot);
|
||||
};
|
||||
|
||||
socket.onclose = (event) => {
|
||||
if (socket !== healthSocket) return;
|
||||
healthSocket = null;
|
||||
|
||||
if (event.code === 4401) {
|
||||
onUnauthorized();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!token.value || selectedPort.value !== proxyPort || event.code === 1000) {
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleHealthReconnect(proxyPort);
|
||||
};
|
||||
}
|
||||
|
||||
function stopHealthStream() {
|
||||
if (typeof window !== "undefined" && healthReconnectTimer !== null) {
|
||||
window.clearTimeout(healthReconnectTimer);
|
||||
healthReconnectTimer = null;
|
||||
}
|
||||
if (healthSocket === null) return;
|
||||
healthSocket.onclose = null;
|
||||
healthSocket.close();
|
||||
healthSocket = null;
|
||||
}
|
||||
|
||||
function openHealthModal() {
|
||||
healthModalOpen.value = true;
|
||||
}
|
||||
|
||||
function closeHealthModal() {
|
||||
healthModalOpen.value = false;
|
||||
}
|
||||
|
||||
function resetHealth() {
|
||||
stopHealthStream();
|
||||
health.value = null;
|
||||
healthHistory.value = [];
|
||||
healthModalOpen.value = false;
|
||||
}
|
||||
|
||||
watch(
|
||||
[selectedPort, token],
|
||||
([port, authToken], [previousPort]) => {
|
||||
if (port !== previousPort) {
|
||||
health.value = null;
|
||||
healthHistory.value = [];
|
||||
}
|
||||
stopHealthStream();
|
||||
if (!authToken || port === null) return;
|
||||
void openHealthStream(port);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
return {
|
||||
closeHealthModal,
|
||||
health,
|
||||
healthHistory,
|
||||
healthModalOpen,
|
||||
openHealthModal,
|
||||
pushHealthSample,
|
||||
resetHealth,
|
||||
stopHealthStream,
|
||||
syncHealthState,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { computed, ref, watch, type Ref } from "vue";
|
||||
|
||||
import { api, buildSocketUrl } from "../api";
|
||||
|
||||
type LogFrame = { type: "snapshot" | "append"; log: string };
|
||||
|
||||
type LogPlaneOptions = {
|
||||
token: Ref<string | null>;
|
||||
selectedPort: Ref<number | null>;
|
||||
onUnauthorized: () => void;
|
||||
onError: (error: unknown) => void;
|
||||
};
|
||||
|
||||
export function useControlPlaneLogs({
|
||||
token,
|
||||
selectedPort,
|
||||
onUnauthorized,
|
||||
onError,
|
||||
}: LogPlaneOptions) {
|
||||
const logs = ref("");
|
||||
const logFilter = ref("");
|
||||
const logsPaused = ref(false);
|
||||
const logsCleared = ref(false);
|
||||
|
||||
let logSocket: WebSocket | null = null;
|
||||
let logReconnectTimer: number | null = null;
|
||||
|
||||
const filteredLogs = computed(() => {
|
||||
const source = logs.value || "";
|
||||
const query = logFilter.value.trim().toLowerCase();
|
||||
if (!query) return source;
|
||||
return source
|
||||
.split("\n")
|
||||
.filter((line) => line.toLowerCase().includes(query))
|
||||
.join("\n");
|
||||
});
|
||||
|
||||
function parseLogFrame(raw: string): LogFrame | null {
|
||||
try {
|
||||
const payload = JSON.parse(raw);
|
||||
if (
|
||||
(payload.type === "snapshot" || payload.type === "append") &&
|
||||
typeof payload.log === "string"
|
||||
) {
|
||||
return payload as LogFrame;
|
||||
}
|
||||
} catch {
|
||||
return { type: "append", log: raw };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function syncLogs(silent = false) {
|
||||
const port = selectedPort.value;
|
||||
if (port === null) return;
|
||||
try {
|
||||
if (!logsCleared.value) {
|
||||
logs.value = (await api.logs(port)).log;
|
||||
}
|
||||
} catch (error) {
|
||||
if (!silent) onError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
await syncLogs(false);
|
||||
}
|
||||
|
||||
function scheduleLogReconnect(proxyPort: number) {
|
||||
if (typeof window === "undefined" || logReconnectTimer !== null) return;
|
||||
logReconnectTimer = window.setTimeout(() => {
|
||||
logReconnectTimer = null;
|
||||
if (!token.value || logsPaused.value || selectedPort.value !== proxyPort) return;
|
||||
void openLogStream(proxyPort);
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
async function openLogStream(proxyPort: number) {
|
||||
if (!logsCleared.value) {
|
||||
await syncLogs(true);
|
||||
}
|
||||
|
||||
if (
|
||||
typeof window === "undefined" ||
|
||||
!token.value ||
|
||||
logsPaused.value ||
|
||||
selectedPort.value !== proxyPort
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const socket = new WebSocket(buildSocketUrl(`/proxy/${proxyPort}/logs/ws`));
|
||||
logSocket = socket;
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
if (socket !== logSocket) return;
|
||||
const frame = parseLogFrame(event.data);
|
||||
if (frame === null) return;
|
||||
if (frame.type === "snapshot") {
|
||||
if (logsCleared.value) return;
|
||||
logs.value = frame.log;
|
||||
return;
|
||||
}
|
||||
logs.value += frame.log;
|
||||
};
|
||||
|
||||
socket.onclose = (event) => {
|
||||
if (socket !== logSocket) return;
|
||||
logSocket = null;
|
||||
|
||||
if (event.code === 4401) {
|
||||
onUnauthorized();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!token.value ||
|
||||
logsPaused.value ||
|
||||
selectedPort.value !== proxyPort ||
|
||||
event.code === 1000
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleLogReconnect(proxyPort);
|
||||
};
|
||||
}
|
||||
|
||||
function stopLogStream() {
|
||||
if (typeof window !== "undefined" && logReconnectTimer !== null) {
|
||||
window.clearTimeout(logReconnectTimer);
|
||||
logReconnectTimer = null;
|
||||
}
|
||||
if (logSocket === null) return;
|
||||
logSocket.onclose = null;
|
||||
logSocket.close();
|
||||
logSocket = null;
|
||||
}
|
||||
|
||||
function toggleLogsPaused() {
|
||||
logsPaused.value = !logsPaused.value;
|
||||
}
|
||||
|
||||
function clearLogs() {
|
||||
logs.value = "";
|
||||
logsCleared.value = true;
|
||||
}
|
||||
|
||||
function resetLogs() {
|
||||
stopLogStream();
|
||||
logs.value = "";
|
||||
logFilter.value = "";
|
||||
logsPaused.value = false;
|
||||
logsCleared.value = false;
|
||||
}
|
||||
|
||||
watch(
|
||||
[selectedPort, token, logsPaused],
|
||||
([port, authToken, paused], [previousPort]) => {
|
||||
if (port !== previousPort) {
|
||||
logs.value = "";
|
||||
logFilter.value = "";
|
||||
logsCleared.value = false;
|
||||
}
|
||||
stopLogStream();
|
||||
if (!authToken || port === null || paused) return;
|
||||
void openLogStream(port);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
return {
|
||||
clearLogs,
|
||||
filteredLogs,
|
||||
loadLogs,
|
||||
logFilter,
|
||||
logs,
|
||||
logsPaused,
|
||||
logsCleared,
|
||||
resetLogs,
|
||||
stopLogStream,
|
||||
syncLogs,
|
||||
toggleLogsPaused,
|
||||
};
|
||||
}
|
||||
@@ -12,25 +12,31 @@ import { useRoute, useRouter } from "vue-router";
|
||||
import {
|
||||
ApiError,
|
||||
api,
|
||||
buildSocketUrl,
|
||||
getToken,
|
||||
login,
|
||||
logout,
|
||||
setToken,
|
||||
} from "../api";
|
||||
import {
|
||||
bindingsFor,
|
||||
createDefaultDraft,
|
||||
draftForService,
|
||||
draftPayload,
|
||||
folderBasename,
|
||||
serializeDraft,
|
||||
} from "./controlPlaneDraft";
|
||||
import {
|
||||
defaultFor,
|
||||
templateProtocolForDraft,
|
||||
type OptionScope,
|
||||
type TemplateProtocolFilter,
|
||||
useControlPlaneCatalog,
|
||||
} from "./controlPlaneCatalog";
|
||||
import { useControlPlaneDiscovery } from "./controlPlaneDiscovery";
|
||||
import { useControlPlaneHealth } from "./controlPlaneHealth";
|
||||
import { useControlPlaneLogs } from "./controlPlaneLogs";
|
||||
export { createDefaultDraft, modes, protocols } from "./controlPlaneDraft";
|
||||
import type {
|
||||
DockerService,
|
||||
DockerServiceBinding,
|
||||
HealthResult,
|
||||
MitmOption,
|
||||
ProxyDocument,
|
||||
ProxyDraft,
|
||||
@@ -40,26 +46,7 @@ import type {
|
||||
const optionScopeStorageKey = "theplug.option.scope";
|
||||
const scanRootStorageKey = "theplug.scan.root";
|
||||
|
||||
const focusedMitmOptions = new Set([
|
||||
"anticache",
|
||||
"block_global",
|
||||
"connection_strategy",
|
||||
"flow_detail",
|
||||
"http2",
|
||||
"http3",
|
||||
"keep_host_header",
|
||||
"rawtcp",
|
||||
"showhost",
|
||||
"ssl_insecure",
|
||||
"termlog_verbosity",
|
||||
"upstream_cert",
|
||||
"validate_inbound_headers",
|
||||
"websocket",
|
||||
]);
|
||||
|
||||
export type RuntimeAction = "start" | "stop" | "restart";
|
||||
export type OptionScope = "focused" | "all";
|
||||
export type TemplateProtocolFilter = "http" | "tcp";
|
||||
type ErrorSurface = "auth" | "wizard" | "discovery" | "workspace";
|
||||
|
||||
function readSession(key: string, fallback: string) {
|
||||
@@ -128,11 +115,6 @@ export function useControlPlane() {
|
||||
const templates = ref<ProxyTemplate[]>([]);
|
||||
const backendPort = ref<number | null>(null);
|
||||
const selectedPort = ref<number | null>(null);
|
||||
const health = ref<HealthResult | null>(null);
|
||||
const healthHistory = ref<HealthResult[]>([]);
|
||||
const logs = ref("");
|
||||
const logFilter = ref("");
|
||||
const logsPaused = ref(false);
|
||||
const optionSearch = ref("");
|
||||
const templateSearch = ref("");
|
||||
const customTemplateName = ref("");
|
||||
@@ -150,22 +132,41 @@ export function useControlPlane() {
|
||||
const wizardOpen = ref(false);
|
||||
const wizardMode = ref<"create" | "edit">("create");
|
||||
const wizardStep = ref(0);
|
||||
const healthModalOpen = ref(false);
|
||||
const wizardSeed = ref<{
|
||||
service: DockerService;
|
||||
binding: DockerServiceBinding;
|
||||
} | null>(null);
|
||||
const logsCleared = ref(false);
|
||||
let noticeTimer: number | null = null;
|
||||
let logSocket: WebSocket | null = null;
|
||||
let logReconnectTimer: number | null = null;
|
||||
let healthSocket: WebSocket | null = null;
|
||||
let healthReconnectTimer: number | null = null;
|
||||
let syncingRoute = false;
|
||||
|
||||
const selectedProxy = computed(() =>
|
||||
proxies.value.find((proxy) => proxy.proxy_port === selectedPort.value),
|
||||
);
|
||||
const logPlane = useControlPlaneLogs({
|
||||
token,
|
||||
selectedPort,
|
||||
onUnauthorized: () => clearSession("Session expired. Sign in again."),
|
||||
onError: (error) => setError(error, "workspace"),
|
||||
});
|
||||
const discoveryPlane = useControlPlaneDiscovery(services, proxies);
|
||||
const healthPlane = useControlPlaneHealth({
|
||||
token,
|
||||
selectedPort,
|
||||
onUnauthorized: () => clearSession("Session expired. Sign in again."),
|
||||
onError: (error) => setError(error, "workspace"),
|
||||
});
|
||||
const catalogPlane = useControlPlaneCatalog({
|
||||
options,
|
||||
optionsLoading,
|
||||
optionSearch,
|
||||
optionScope,
|
||||
selectedOptionName,
|
||||
templates,
|
||||
templateSearch,
|
||||
templateProtocolFilter,
|
||||
selectedTemplateId,
|
||||
draft,
|
||||
});
|
||||
|
||||
const runtimeStatus = computed(() => selectedProxy.value?.status ?? "draft");
|
||||
const isRunning = computed(() =>
|
||||
@@ -211,15 +212,6 @@ export function useControlPlane() {
|
||||
const wizardError = computed(() => errors.wizard);
|
||||
const discoveryError = computed(() => errors.discovery);
|
||||
const workspaceError = computed(() => errors.workspace);
|
||||
const filteredLogs = computed(() => {
|
||||
const source = logs.value || "";
|
||||
const query = logFilter.value.trim().toLowerCase();
|
||||
if (!query) return source;
|
||||
return source
|
||||
.split("\n")
|
||||
.filter((line) => line.toLowerCase().includes(query))
|
||||
.join("\n");
|
||||
});
|
||||
|
||||
function buildRouteQuery() {
|
||||
const query: Record<string, string | undefined> = {
|
||||
@@ -284,71 +276,6 @@ export function useControlPlane() {
|
||||
}
|
||||
}
|
||||
|
||||
const serviceEntries = computed(() =>
|
||||
services.value.flatMap((service) =>
|
||||
bindingsFor(service).map((binding) => {
|
||||
const proxy =
|
||||
proxies.value.find(
|
||||
(item) =>
|
||||
item.service.name === service.name &&
|
||||
item.service.folder === service.folder &&
|
||||
item.service.port === binding.published,
|
||||
) ?? null;
|
||||
|
||||
return {
|
||||
id: `${service.folder}:${service.name}:${binding.host ?? "*"}:${binding.published}`,
|
||||
service,
|
||||
binding,
|
||||
proxy,
|
||||
listenHost: binding.host ?? "0.0.0.0",
|
||||
};
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const missingServiceCount = computed(
|
||||
() => serviceEntries.value.filter((entry) => !entry.proxy).length,
|
||||
);
|
||||
|
||||
const serviceGroups = computed(() => {
|
||||
const groups = new Map<
|
||||
string,
|
||||
{
|
||||
id: string;
|
||||
title: string;
|
||||
folder: string;
|
||||
entries: (typeof serviceEntries.value)[number][];
|
||||
}
|
||||
>();
|
||||
|
||||
for (const entry of serviceEntries.value) {
|
||||
const folder = entry.service.folder ?? "";
|
||||
const existing = groups.get(folder);
|
||||
if (existing) {
|
||||
existing.entries.push(entry);
|
||||
continue;
|
||||
}
|
||||
|
||||
groups.set(folder, {
|
||||
id: folder || entry.service.name,
|
||||
title: folderBasename(folder) || "Compose folder",
|
||||
folder,
|
||||
entries: [entry],
|
||||
});
|
||||
}
|
||||
|
||||
return [...groups.values()]
|
||||
.map((group) => ({
|
||||
...group,
|
||||
entries: [...group.entries].sort((left, right) => {
|
||||
const byName = left.service.name.localeCompare(right.service.name);
|
||||
if (byName !== 0) return byName;
|
||||
return left.binding.published - right.binding.published;
|
||||
}),
|
||||
}))
|
||||
.sort((left, right) => left.title.localeCompare(right.title));
|
||||
});
|
||||
|
||||
const routeState = computed(() => {
|
||||
const livePort =
|
||||
selectedProxy.value?.service.upstream_port ?? draft.service.upstream_port ?? null;
|
||||
@@ -388,73 +315,6 @@ export function useControlPlane() {
|
||||
};
|
||||
});
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
if (optionsLoading.value) return [];
|
||||
const query = optionSearch.value.toLowerCase().trim();
|
||||
const available = options.value.filter((option) => !option.reserved);
|
||||
const scoped = query
|
||||
? available
|
||||
: optionScope.value === "all"
|
||||
? available
|
||||
: available.filter((option) => focusedMitmOptions.has(option.name));
|
||||
const filtered = scoped
|
||||
.filter((option) => {
|
||||
if (!query) return true;
|
||||
return `${option.name} ${option.description}`.toLowerCase().includes(query);
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return filtered.slice(0, query ? 80 : optionScope.value === "all" ? 80 : 18);
|
||||
});
|
||||
|
||||
const optionCount = computed(
|
||||
() => options.value.filter((option) => !option.reserved).length,
|
||||
);
|
||||
const activeOptionCount = computed(() => Object.keys(draft.mitm_options).length);
|
||||
|
||||
const selectedOption = computed(
|
||||
() =>
|
||||
filteredOptions.value.find((option) => option.name === selectedOptionName.value) ??
|
||||
filteredOptions.value[0] ??
|
||||
null,
|
||||
);
|
||||
|
||||
const filteredTemplates = computed(() => {
|
||||
const query = templateSearch.value.toLowerCase().trim();
|
||||
const protocol = templateProtocolFilter.value;
|
||||
const filtered = templates.value.filter((template) => {
|
||||
if (template.protocol !== protocol) return false;
|
||||
if (!query) return true;
|
||||
return [
|
||||
template.name,
|
||||
template.description,
|
||||
template.summary,
|
||||
template.behavior,
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(query);
|
||||
});
|
||||
const sorted = filtered.sort((a, b) => a.name.localeCompare(b.name));
|
||||
return query ? sorted : sorted.slice(0, 40);
|
||||
});
|
||||
|
||||
const selectedTemplate = computed(
|
||||
() =>
|
||||
filteredTemplates.value.find((template) => template.id === selectedTemplateId.value) ??
|
||||
filteredTemplates.value[0] ??
|
||||
null,
|
||||
);
|
||||
|
||||
const activeOptions = computed(() =>
|
||||
Object.entries(draft.mitm_options)
|
||||
.map(([name, value]) => ({
|
||||
name,
|
||||
value: Array.isArray(value) ? value.join(", ") : String(value ?? ""),
|
||||
option: options.value.find((item) => item.name === name),
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
);
|
||||
|
||||
const codeLineCount = computed(() => draft.code.split("\n").length);
|
||||
const scriptDirty = computed(() => draft.code !== loadedCode.value);
|
||||
const draftDirty = computed(
|
||||
@@ -514,47 +374,18 @@ export function useControlPlane() {
|
||||
writeSession(scanRootStorageKey, value);
|
||||
});
|
||||
|
||||
watch(filteredOptions, (items) => {
|
||||
watch(catalogPlane.filteredOptions, (items) => {
|
||||
if (!items.some((item) => item.name === selectedOptionName.value)) {
|
||||
selectedOptionName.value = items[0]?.name ?? "";
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
watch(filteredTemplates, (items) => {
|
||||
watch(catalogPlane.filteredTemplates, (items) => {
|
||||
if (!items.some((item) => item.id === selectedTemplateId.value)) {
|
||||
selectedTemplateId.value = items[0]?.id ?? "";
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
watch(
|
||||
[selectedPort, token],
|
||||
([port, authToken], [previousPort]) => {
|
||||
if (port !== previousPort) {
|
||||
health.value = null;
|
||||
healthHistory.value = [];
|
||||
}
|
||||
stopHealthStream();
|
||||
if (!authToken || port === null) return;
|
||||
void openHealthStream(port);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
[selectedPort, token, logsPaused],
|
||||
([port, authToken, paused], [previousPort]) => {
|
||||
if (port !== previousPort) {
|
||||
logs.value = "";
|
||||
logFilter.value = "";
|
||||
logsCleared.value = false;
|
||||
}
|
||||
stopLogStream();
|
||||
if (!authToken || port === null || paused) return;
|
||||
void openLogStream(port);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
async function signIn() {
|
||||
await run(async () => {
|
||||
await login(username.value, password.value);
|
||||
@@ -671,23 +502,15 @@ export function useControlPlane() {
|
||||
typeof seedOrAnnounce === "boolean" ? seedOrAnnounce : announce;
|
||||
selectedPort.value = null;
|
||||
loadedPort.value = null;
|
||||
health.value = null;
|
||||
logs.value = "";
|
||||
logFilter.value = "";
|
||||
logsCleared.value = false;
|
||||
const nextDraft = createDefaultDraft(
|
||||
nextPort(),
|
||||
templates.value.find((template) => template.protocol === "http")?.code ??
|
||||
draft.code,
|
||||
);
|
||||
healthPlane.health.value = null;
|
||||
logPlane.logs.value = "";
|
||||
logPlane.logFilter.value = "";
|
||||
logPlane.logsCleared.value = false;
|
||||
const nextDraft = createDefaultDraft(nextPort());
|
||||
if (seed) {
|
||||
Object.assign(
|
||||
nextDraft,
|
||||
draftForService(
|
||||
seed.service,
|
||||
seed.binding,
|
||||
templates.value.find((template) => template.protocol === "http")?.code,
|
||||
),
|
||||
draftForService(seed.service, seed.binding),
|
||||
);
|
||||
}
|
||||
Object.assign(draft, nextDraft);
|
||||
@@ -759,15 +582,9 @@ export function useControlPlane() {
|
||||
|
||||
async function createMissingServiceProxies() {
|
||||
await run(async () => {
|
||||
for (const entry of serviceEntries.value) {
|
||||
for (const entry of discoveryPlane.serviceEntries.value) {
|
||||
if (entry.proxy) continue;
|
||||
await api.createProxy(
|
||||
draftForService(
|
||||
entry.service,
|
||||
entry.binding,
|
||||
templates.value.find((template) => template.protocol === "http")?.code,
|
||||
),
|
||||
);
|
||||
await api.createProxy(draftForService(entry.service, entry.binding));
|
||||
}
|
||||
await Promise.all([refreshProxies(false), refreshServicesRequest(false)]);
|
||||
const first = proxies.value[0];
|
||||
@@ -867,212 +684,17 @@ export function useControlPlane() {
|
||||
if (!proxy) return;
|
||||
await run(async () => {
|
||||
const snapshot = await api.health(proxy.proxy_port);
|
||||
health.value = snapshot;
|
||||
pushHealthSample(snapshot);
|
||||
healthPlane.health.value = snapshot;
|
||||
healthPlane.pushHealthSample(snapshot);
|
||||
}, undefined, "workspace");
|
||||
}
|
||||
|
||||
function openHealthModal() {
|
||||
healthModalOpen.value = true;
|
||||
}
|
||||
|
||||
function closeHealthModal() {
|
||||
healthModalOpen.value = false;
|
||||
}
|
||||
|
||||
function openDiscovery() {
|
||||
clearError();
|
||||
closeHealthModal();
|
||||
healthPlane.closeHealthModal();
|
||||
selectedPort.value = null;
|
||||
}
|
||||
|
||||
async function syncHealthState(silent = false) {
|
||||
const proxy = selectedProxy.value;
|
||||
if (!proxy) return;
|
||||
try {
|
||||
const response = await api.healthHistory(proxy.proxy_port);
|
||||
health.value = response.latest;
|
||||
healthHistory.value = response.history;
|
||||
} catch (err) {
|
||||
if (!silent) {
|
||||
setError(err, "workspace");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
await syncLogs(false);
|
||||
}
|
||||
|
||||
async function syncLogs(silent = false) {
|
||||
const proxy = selectedProxy.value;
|
||||
if (!proxy) return;
|
||||
try {
|
||||
if (!logsCleared.value) {
|
||||
logs.value = (await api.logs(proxy.proxy_port)).log;
|
||||
}
|
||||
} catch (err) {
|
||||
if (!silent) {
|
||||
setError(err, "workspace");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function openLogStream(proxyPort: number) {
|
||||
if (!logsCleared.value) {
|
||||
await syncLogs(true);
|
||||
}
|
||||
|
||||
if (
|
||||
typeof window === "undefined" ||
|
||||
!token.value ||
|
||||
logsPaused.value ||
|
||||
selectedPort.value !== proxyPort
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const socket = new WebSocket(buildSocketUrl(`/proxy/${proxyPort}/logs/ws`));
|
||||
logSocket = socket;
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
if (socket !== logSocket) return;
|
||||
const frame = parseLogFrame(event.data);
|
||||
if (frame === null) return;
|
||||
if (frame.type === "snapshot") {
|
||||
if (logsCleared.value) return;
|
||||
logs.value = frame.log;
|
||||
return;
|
||||
}
|
||||
logs.value += frame.log;
|
||||
};
|
||||
|
||||
socket.onclose = (event) => {
|
||||
if (socket !== logSocket) return;
|
||||
logSocket = null;
|
||||
|
||||
if (event.code === 4401) {
|
||||
clearSession("Session expired. Sign in again.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!token.value ||
|
||||
logsPaused.value ||
|
||||
selectedPort.value !== proxyPort ||
|
||||
event.code === 1000
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleLogReconnect(proxyPort);
|
||||
};
|
||||
}
|
||||
|
||||
async function openHealthStream(proxyPort: number) {
|
||||
await syncHealthState(true);
|
||||
|
||||
if (
|
||||
typeof window === "undefined" ||
|
||||
!token.value ||
|
||||
selectedPort.value !== proxyPort
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const socket = new WebSocket(buildSocketUrl(`/proxy/${proxyPort}/health/ws`));
|
||||
healthSocket = socket;
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
if (socket !== healthSocket) return;
|
||||
const snapshot = parseHealthFrame(event.data);
|
||||
if (snapshot === null) return;
|
||||
health.value = snapshot;
|
||||
pushHealthSample(snapshot);
|
||||
};
|
||||
|
||||
socket.onclose = (event) => {
|
||||
if (socket !== healthSocket) return;
|
||||
healthSocket = null;
|
||||
|
||||
if (event.code === 4401) {
|
||||
clearSession("Session expired. Sign in again.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!token.value || selectedPort.value !== proxyPort || event.code === 1000) {
|
||||
return;
|
||||
}
|
||||
|
||||
scheduleHealthReconnect(proxyPort);
|
||||
};
|
||||
}
|
||||
|
||||
function parseLogFrame(raw: string) {
|
||||
try {
|
||||
const payload = JSON.parse(raw);
|
||||
if (
|
||||
(payload.type === "snapshot" || payload.type === "append") &&
|
||||
typeof payload.log === "string"
|
||||
) {
|
||||
return payload as { type: "snapshot" | "append"; log: string };
|
||||
}
|
||||
} catch {
|
||||
return { type: "append", log: raw } as const;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function scheduleLogReconnect(proxyPort: number) {
|
||||
if (typeof window === "undefined" || logReconnectTimer !== null) return;
|
||||
logReconnectTimer = window.setTimeout(() => {
|
||||
logReconnectTimer = null;
|
||||
if (!token.value || logsPaused.value || selectedPort.value !== proxyPort) return;
|
||||
void openLogStream(proxyPort);
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
function parseHealthFrame(raw: string) {
|
||||
try {
|
||||
const payload = JSON.parse(raw) as HealthResult;
|
||||
if (
|
||||
typeof payload?.proxy_port === "number" &&
|
||||
typeof payload?.timestamp === "string" &&
|
||||
Array.isArray(payload?.checks)
|
||||
) {
|
||||
return payload;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function scheduleHealthReconnect(proxyPort: number) {
|
||||
if (typeof window === "undefined" || healthReconnectTimer !== null) return;
|
||||
healthReconnectTimer = window.setTimeout(() => {
|
||||
healthReconnectTimer = null;
|
||||
if (!token.value || selectedPort.value !== proxyPort) return;
|
||||
void openHealthStream(proxyPort);
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
function pushHealthSample(snapshot: HealthResult) {
|
||||
const next = [...healthHistory.value];
|
||||
const index = next.findIndex((item) => item.timestamp === snapshot.timestamp);
|
||||
if (index >= 0) {
|
||||
next[index] = snapshot;
|
||||
} else {
|
||||
next.push(snapshot);
|
||||
}
|
||||
next.sort((left, right) => left.timestamp.localeCompare(right.timestamp));
|
||||
healthHistory.value = next.slice(-40);
|
||||
}
|
||||
|
||||
function templateProtocolForDraft(): "http" | "tcp" {
|
||||
return ["http", "https", "http3"].includes(draft.protocol) ? "http" : "tcp";
|
||||
}
|
||||
|
||||
async function saveCurrentAsTemplate() {
|
||||
const name = customTemplateName.value.trim();
|
||||
if (!name) {
|
||||
@@ -1087,8 +709,8 @@ export function useControlPlane() {
|
||||
id,
|
||||
name,
|
||||
description: `Saved from ${draft.name} (${draft.mode} / ${draft.protocol}).`,
|
||||
category: templateProtocolForDraft(),
|
||||
protocol: templateProtocolForDraft(),
|
||||
category: templateProtocolForDraft(draft),
|
||||
protocol: templateProtocolForDraft(draft),
|
||||
family: "custom",
|
||||
tags: [],
|
||||
action: "custom",
|
||||
@@ -1099,7 +721,7 @@ export function useControlPlane() {
|
||||
});
|
||||
await refreshTemplates(false);
|
||||
customTemplateName.value = "";
|
||||
templateProtocolFilter.value = templateProtocolForDraft();
|
||||
templateProtocolFilter.value = templateProtocolForDraft(draft);
|
||||
selectedTemplateId.value = id;
|
||||
}, "Custom template saved", "workspace");
|
||||
}
|
||||
@@ -1191,15 +813,6 @@ export function useControlPlane() {
|
||||
optionScope.value = scope;
|
||||
}
|
||||
|
||||
function toggleLogsPaused() {
|
||||
logsPaused.value = !logsPaused.value;
|
||||
}
|
||||
|
||||
function clearLogs() {
|
||||
logs.value = "";
|
||||
logsCleared.value = true;
|
||||
}
|
||||
|
||||
function cleanDraft(): ProxyDraft {
|
||||
return draftPayload(draft);
|
||||
}
|
||||
@@ -1210,8 +823,8 @@ export function useControlPlane() {
|
||||
loadedPort.value = proxy.proxy_port;
|
||||
loadedDraftJson.value = serializeDraft(nextDraft);
|
||||
loadedCode.value = nextDraft.code;
|
||||
health.value = null;
|
||||
logs.value = "";
|
||||
healthPlane.health.value = null;
|
||||
logPlane.logs.value = "";
|
||||
}
|
||||
|
||||
function syncLoadedCode(code: string) {
|
||||
@@ -1231,14 +844,6 @@ export function useControlPlane() {
|
||||
return port;
|
||||
}
|
||||
|
||||
function defaultFor(option: MitmOption) {
|
||||
if (option.default !== null && option.default !== undefined) return option.default;
|
||||
if (option.type === "bool") return false;
|
||||
if (option.type === "int" || option.type === "optional int") return 0;
|
||||
if (option.type === "sequence of str") return [];
|
||||
return "";
|
||||
}
|
||||
|
||||
function activeSurface(): ErrorSurface {
|
||||
if (!token.value) return "auth";
|
||||
if (wizardOpen.value) return "wizard";
|
||||
@@ -1300,8 +905,8 @@ export function useControlPlane() {
|
||||
}
|
||||
|
||||
function clearSession(message = "") {
|
||||
stopHealthStream();
|
||||
stopLogStream();
|
||||
healthPlane.stopHealthStream();
|
||||
logPlane.stopLogStream();
|
||||
setToken(null);
|
||||
token.value = null;
|
||||
proxies.value = [];
|
||||
@@ -1311,15 +916,12 @@ export function useControlPlane() {
|
||||
backendPort.value = null;
|
||||
selectedPort.value = null;
|
||||
loadedPort.value = null;
|
||||
health.value = null;
|
||||
logs.value = "";
|
||||
logFilter.value = "";
|
||||
logsCleared.value = false;
|
||||
healthPlane.resetHealth();
|
||||
logPlane.resetLogs();
|
||||
wizardOpen.value = false;
|
||||
wizardStep.value = 0;
|
||||
wizardMode.value = "create";
|
||||
wizardSeed.value = null;
|
||||
healthModalOpen.value = false;
|
||||
templateProtocolFilter.value = "http";
|
||||
Object.assign(draft, createDefaultDraft());
|
||||
loadedDraftJson.value = serializeDraft(draft);
|
||||
@@ -1328,44 +930,18 @@ export function useControlPlane() {
|
||||
void router.replace({ path: "/proxies", query: {} });
|
||||
}
|
||||
|
||||
function stopLogStream() {
|
||||
if (typeof window !== "undefined" && logReconnectTimer !== null) {
|
||||
window.clearTimeout(logReconnectTimer);
|
||||
logReconnectTimer = null;
|
||||
}
|
||||
if (logSocket === null) return;
|
||||
logSocket.onclose = null;
|
||||
logSocket.close();
|
||||
logSocket = null;
|
||||
}
|
||||
|
||||
function stopHealthStream() {
|
||||
if (typeof window !== "undefined" && healthReconnectTimer !== null) {
|
||||
window.clearTimeout(healthReconnectTimer);
|
||||
healthReconnectTimer = null;
|
||||
}
|
||||
if (healthSocket === null) return;
|
||||
healthSocket.onclose = null;
|
||||
healthSocket.close();
|
||||
healthSocket = null;
|
||||
}
|
||||
|
||||
return proxyRefs({
|
||||
activeOptionCount,
|
||||
activeOptions,
|
||||
activeOptionCount: catalogPlane.activeOptionCount,
|
||||
activeOptions: catalogPlane.activeOptions,
|
||||
applyTemplate,
|
||||
applyService,
|
||||
actionState,
|
||||
authMessage,
|
||||
checkHealth,
|
||||
clearLogs,
|
||||
closeWizard,
|
||||
codeLineCount,
|
||||
canSaveScript,
|
||||
canSaveProxy,
|
||||
filteredLogs,
|
||||
logFilter,
|
||||
logsPaused,
|
||||
createMissingServiceProxies,
|
||||
createProxyFromService,
|
||||
customTemplateName,
|
||||
@@ -1373,23 +949,17 @@ export function useControlPlane() {
|
||||
error,
|
||||
templateSearch,
|
||||
templateProtocolFilter,
|
||||
filteredTemplates,
|
||||
filteredOptions,
|
||||
health,
|
||||
healthHistory,
|
||||
healthModalOpen,
|
||||
filteredTemplates: catalogPlane.filteredTemplates,
|
||||
filteredOptions: catalogPlane.filteredOptions,
|
||||
discoveryError,
|
||||
loginError,
|
||||
loadLogs,
|
||||
loading,
|
||||
logs,
|
||||
newProxy,
|
||||
notice,
|
||||
openCreateWizard,
|
||||
openDiscovery,
|
||||
openEditWizard,
|
||||
openHealthModal,
|
||||
optionCount,
|
||||
optionCount: catalogPlane.optionCount,
|
||||
optionScope,
|
||||
optionSearch,
|
||||
password,
|
||||
@@ -1407,13 +977,13 @@ export function useControlPlane() {
|
||||
saveProxy,
|
||||
saveScript,
|
||||
selectTemplate,
|
||||
selectedTemplate,
|
||||
selectedOption,
|
||||
selectedTemplate: catalogPlane.selectedTemplate,
|
||||
selectedOption: catalogPlane.selectedOption,
|
||||
selectedPort,
|
||||
selectedProxy,
|
||||
serviceGroups,
|
||||
serviceGroups: discoveryPlane.serviceGroups,
|
||||
selectOption,
|
||||
serviceEntries,
|
||||
serviceEntries: discoveryPlane.serviceEntries,
|
||||
services,
|
||||
templatesLoading,
|
||||
proxiesLoading,
|
||||
@@ -1421,12 +991,10 @@ export function useControlPlane() {
|
||||
setOption,
|
||||
signIn,
|
||||
signOut,
|
||||
closeHealthModal,
|
||||
matchProxyPortToService,
|
||||
missingServiceCount,
|
||||
missingServiceCount: discoveryPlane.missingServiceCount,
|
||||
nextWizardStep,
|
||||
toggleOptionScope,
|
||||
toggleLogsPaused,
|
||||
token,
|
||||
updateOption,
|
||||
username,
|
||||
@@ -1442,6 +1010,8 @@ export function useControlPlane() {
|
||||
canGoNextWizard,
|
||||
scanRoot,
|
||||
servicesLoading,
|
||||
...logPlane,
|
||||
...healthPlane,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@ export type ProxyTemplate = {
|
||||
code: string;
|
||||
source_url?: string | null;
|
||||
built_in: boolean;
|
||||
origin: "file" | "custom";
|
||||
};
|
||||
|
||||
export type ProxyTemplateCreateInput = {
|
||||
|
||||
Reference in New Issue
Block a user