Files
thePlug/frontend/src/composables/useControlPlane.ts
T
2026-05-07 09:21:45 +02:00

1459 lines
40 KiB
TypeScript

import {
computed,
inject,
proxyRefs,
reactive,
ref,
watch,
type InjectionKey,
} from "vue";
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";
export { createDefaultDraft, modes, protocols } from "./controlPlaneDraft";
import type {
DockerService,
DockerServiceBinding,
HealthResult,
MitmOption,
ProxyDocument,
ProxyDraft,
ProxyTemplate,
} from "../types";
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) {
if (typeof window === "undefined") return fallback;
return window.sessionStorage.getItem(key) ?? fallback;
}
function writeSession(key: string, value: string) {
if (typeof window === "undefined") return;
window.sessionStorage.setItem(key, value);
}
function parseProxyPort(value: unknown): number | null {
const port = Number(value);
return Number.isInteger(port) && port > 0 ? port : null;
}
function parseWizardMode(value: unknown): "create" | "edit" | null {
return value === "create" || value === "edit" ? value : null;
}
function parseWizardStep(value: unknown): number {
const step = Number(value);
return step >= 0 && step <= 2 ? step : 0;
}
function sameQuery(
current: Record<string, unknown>,
expected: Record<string, string | undefined>,
) {
const keys = new Set([...Object.keys(current), ...Object.keys(expected)]);
for (const key of keys) {
const currentValue = current[key];
const normalizedCurrent = Array.isArray(currentValue)
? String(currentValue[0] ?? "")
: typeof currentValue === "string"
? currentValue
: undefined;
if (normalizedCurrent !== expected[key]) return false;
}
return true;
}
export function useControlPlane() {
const router = useRouter();
const route = useRoute();
const token = ref(getToken());
const username = ref("admin");
const password = ref("");
const authMessage = ref("");
const errors = reactive<Record<ErrorSurface, string>>({
auth: "",
wizard: "",
discovery: "",
workspace: "",
});
const notice = ref("");
const loading = ref(false);
const proxiesLoading = ref(Boolean(token.value));
const servicesLoading = ref(Boolean(token.value));
const optionsLoading = ref(Boolean(token.value));
const templatesLoading = ref(Boolean(token.value));
const proxies = ref<ProxyDocument[]>([]);
const services = ref<DockerService[]>([]);
const options = ref<MitmOption[]>([]);
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("");
const templateProtocolFilter = ref<TemplateProtocolFilter>("http");
const scanRoot = ref(readSession(scanRootStorageKey, "/root"));
const optionScope = ref<OptionScope>(
readSession(optionScopeStorageKey, "focused") as OptionScope,
);
const selectedOptionName = ref("");
const selectedTemplateId = ref("");
const draft = reactive<ProxyDraft>(createDefaultDraft());
const loadedPort = ref<number | null>(null);
const loadedDraftJson = ref("");
const loadedCode = ref(draft.code);
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 runtimeStatus = computed(() => selectedProxy.value?.status ?? "draft");
const isRunning = computed(() =>
["running", "starting", "restarting"].includes(runtimeStatus.value),
);
const actionState = computed(() => ({
canSave: !loading.value && !isRunning.value,
canStart:
!loading.value &&
Boolean(selectedProxy.value) &&
!isRunning.value &&
!routeState.value.proxyPortReserved,
canStop: !loading.value && Boolean(selectedProxy.value) && isRunning.value,
canRestart:
!loading.value &&
Boolean(selectedProxy.value) &&
isRunning.value &&
!routeState.value.proxyPortReserved,
canDelete: !loading.value && Boolean(selectedProxy.value),
showSave: !isRunning.value,
showStart: Boolean(selectedProxy.value) && !isRunning.value,
showStop: Boolean(selectedProxy.value) && isRunning.value,
showRestart: Boolean(selectedProxy.value) && isRunning.value,
}));
const canSaveProxy = computed(
() =>
!loading.value &&
(!routeState.value.proxyPortReserved || Boolean(selectedProxy.value)),
);
const wizardTitle = computed(() =>
wizardMode.value === "edit" ? "Edit proxy settings" : "Create proxy",
);
const wizardStepTitle = computed(() => {
return ["Proxy route", "Healthchecks", "Custom mitmdump options"][
wizardStep.value
] ?? "Proxy route";
});
const canGoBackWizard = computed(() => wizardStep.value > 0);
const canGoNextWizard = computed(() => wizardStep.value < 2);
const error = computed(() => errors[activeSurface()]);
const loginError = computed(() => errors.auth);
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> = {
scanRoot: scanRoot.value.trim() && scanRoot.value.trim() !== "/root"
? scanRoot.value.trim()
: undefined,
};
if (wizardOpen.value) {
query.modal = wizardMode.value;
query.step = String(wizardStep.value);
}
return query;
}
async function syncRouteState() {
if (syncingRoute) return;
const path = selectedPort.value ? `/proxies/${selectedPort.value}` : "/proxies";
const query = buildRouteQuery();
if (route.path === path && sameQuery(route.query as Record<string, unknown>, query)) {
return;
}
syncingRoute = true;
try {
await router.replace({ path, query });
} finally {
syncingRoute = false;
}
}
function applyRouteState() {
if (syncingRoute) return;
syncingRoute = true;
try {
selectedPort.value = parseProxyPort(route.params.proxyPort);
const routedScanRoot =
typeof route.query.scanRoot === "string" ? route.query.scanRoot : null;
scanRoot.value = routedScanRoot || readSession(scanRootStorageKey, "/root");
const modal = parseWizardMode(route.query.modal);
const step = parseWizardStep(route.query.step);
if (modal === "create") {
if (!wizardOpen.value || wizardMode.value !== "create") {
prepareDraftForCreate(wizardSeed.value);
}
wizardMode.value = "create";
wizardStep.value = step;
wizardOpen.value = true;
} else if (modal === "edit" && selectedPort.value !== null) {
wizardMode.value = "edit";
wizardStep.value = step;
wizardOpen.value = true;
} else {
wizardOpen.value = false;
wizardStep.value = 0;
}
} finally {
syncingRoute = false;
}
}
const serviceEntries = computed(() =>
services.value.flatMap((service) =>
bindingsFor(service).map((binding) => {
const proxy =
proxies.value.find(
(item) =>
item.service.name === service.name &&
item.service.folder === service.folder &&
item.service.port === binding.published,
) ?? null;
return {
id: `${service.folder}:${service.name}:${binding.host ?? "*"}:${binding.published}`,
service,
binding,
proxy,
listenHost: binding.host ?? "0.0.0.0",
};
}),
),
);
const missingServiceCount = computed(
() => serviceEntries.value.filter((entry) => !entry.proxy).length,
);
const 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;
const currentBackendPort = backendPort.value;
const dockerManaged = Boolean(draft.service.name && draft.service.folder);
const reverseManaged = draft.mode === "reverse" && !draft.mode_spec;
const remapped = livePort !== null && livePort !== draft.service.port;
const proxyPortReserved =
currentBackendPort !== null && draft.proxy_port === currentBackendPort;
const proxyPortReservedMessage = proxyPortReserved
? `Proxy port ${draft.proxy_port} is reserved for the backend API and cannot be used by mitmdump.`
: null;
const configuredTarget = `${draft.service.host}:${draft.service.port}`;
const liveTarget = `${draft.service.host}:${livePort ?? draft.service.port}`;
const takeoverReady =
reverseManaged && dockerManaged && draft.proxy_port === draft.service.port;
const takeoverMismatch =
reverseManaged && dockerManaged && draft.proxy_port !== draft.service.port;
return {
configuredListen: `${draft.listen_host}:${draft.proxy_port}`,
configuredTarget,
liveTarget,
proxySummary: `Proxy ${draft.listen_host}:${draft.proxy_port}`,
upstreamSummary: `Upstream ${draft.service.host}:${livePort ?? draft.service.port}`,
modeLabel: draft.mode_spec ?? `${draft.mode} / ${draft.protocol}`,
serviceLabel: draft.service.name ?? "manual upstream",
backendPort: currentBackendPort,
dockerManaged,
reverseManaged,
remapped,
livePort,
proxyPortReserved,
proxyPortReservedMessage,
takeoverReady,
takeoverMismatch,
};
});
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(
() => serializeDraft(draft) !== loadedDraftJson.value,
);
const canSaveScript = computed(
() =>
!loading.value &&
Boolean(selectedProxy.value) &&
scriptDirty.value,
);
watch(
[selectedPort, proxies],
([port]) => {
if (port === null) return;
const proxy = proxies.value.find((item) => item.proxy_port === port);
if (!proxy) return;
if (port !== loadedPort.value) {
hydrateDraft(proxy);
return;
}
if (!wizardOpen.value && !draftDirty.value) {
hydrateDraft(proxy);
}
},
{ immediate: true },
);
watch(
() => [
route.params.proxyPort,
route.query.modal,
route.query.step,
route.query.scanRoot,
],
() => {
applyRouteState();
},
{ immediate: true },
);
watch(
[selectedPort, wizardOpen, wizardMode, wizardStep, scanRoot],
() => {
void syncRouteState();
},
);
watch(optionScope, (value) => {
writeSession(optionScopeStorageKey, value);
});
watch(scanRoot, (value) => {
writeSession(scanRootStorageKey, value);
});
watch(filteredOptions, (items) => {
if (!items.some((item) => item.name === selectedOptionName.value)) {
selectedOptionName.value = items[0]?.name ?? "";
}
}, { immediate: true });
watch(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);
token.value = getToken();
authMessage.value = "";
password.value = "";
await refreshAll();
}, "Logged in", "auth");
}
async function signOut() {
if (token.value) {
await logout();
}
clearSession();
}
async function refreshProxies(reportErrors = true) {
proxiesLoading.value = true;
if (reportErrors) clearError("workspace");
try {
const proxyList = await api.proxies();
proxies.value = proxyList;
if (!proxyList.some((proxy) => proxy.proxy_port === selectedPort.value)) {
selectedPort.value = null;
}
return proxyList;
} catch (err) {
if (reportErrors) setError(err, "workspace");
return null;
} finally {
proxiesLoading.value = false;
}
}
async function refreshServicesRequest(reportErrors = true) {
servicesLoading.value = true;
if (reportErrors) clearError("discovery");
try {
const response = await api.services(scanRoot.value.trim() || undefined);
scanRoot.value = response.scan_root;
services.value = response.services;
backendPort.value = response.backend_port;
return response;
} catch (err) {
if (reportErrors) setError(err, "discovery");
return null;
} finally {
servicesLoading.value = false;
}
}
async function refreshServices() {
return refreshServicesRequest(true);
}
async function refreshOptions(reportErrors = true) {
optionsLoading.value = true;
if (reportErrors) clearError("wizard");
try {
const optionList = await api.options();
options.value = optionList;
return optionList;
} catch (err) {
if (reportErrors && wizardOpen.value) {
setError(err, "wizard");
}
return null;
} finally {
optionsLoading.value = false;
}
}
async function refreshTemplates(reportErrors = true) {
templatesLoading.value = true;
if (reportErrors) clearError("workspace");
try {
const templateList = await api.templates();
templates.value = templateList;
return templateList;
} catch (err) {
if (reportErrors) setError(err, "workspace");
return null;
} finally {
templatesLoading.value = false;
}
}
async function refreshAll() {
clearError(activeSurface());
await Promise.allSettled([
refreshProxies(false),
refreshServicesRequest(false),
refreshOptions(false),
refreshTemplates(false),
]);
}
function prepareDraftForCreate(
seedOrAnnounce?:
| {
service: DockerService;
binding: DockerServiceBinding;
}
| boolean
| null,
announce = false,
) {
const seed =
typeof seedOrAnnounce === "object" && seedOrAnnounce !== null
? seedOrAnnounce
: wizardSeed.value;
const shouldAnnounce =
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,
);
if (seed) {
Object.assign(
nextDraft,
draftForService(
seed.service,
seed.binding,
templates.value.find((template) => template.protocol === "http")?.code,
),
);
}
Object.assign(draft, nextDraft);
loadedDraftJson.value = serializeDraft(nextDraft);
loadedCode.value = nextDraft.code;
if (shouldAnnounce) {
notice.value = "Draft created.";
}
}
function newProxy() {
prepareDraftForCreate(true);
}
function openCreateWizard(
service?: DockerService,
binding?: DockerServiceBinding,
) {
clearError();
wizardSeed.value = service && binding ? { service, binding } : null;
prepareDraftForCreate(wizardSeed.value);
wizardMode.value = "create";
wizardStep.value = 0;
wizardOpen.value = true;
void syncRouteState();
}
function openEditWizard(proxy = selectedProxy.value) {
if (!proxy) return;
clearError();
wizardSeed.value = null;
selectedPort.value = proxy.proxy_port;
hydrateDraft(proxy);
wizardMode.value = "edit";
wizardStep.value = 0;
wizardOpen.value = true;
void syncRouteState();
}
function closeWizard() {
clearError();
if (wizardMode.value === "edit" && selectedProxy.value) {
hydrateDraft(selectedProxy.value);
} else if (wizardMode.value === "create") {
wizardSeed.value = null;
prepareDraftForCreate();
}
wizardOpen.value = false;
wizardStep.value = 0;
void syncRouteState();
}
function nextWizardStep() {
wizardStep.value = Math.min(wizardStep.value + 1, 2);
void syncRouteState();
}
function previousWizardStep() {
wizardStep.value = Math.max(wizardStep.value - 1, 0);
void syncRouteState();
}
async function createProxyFromService(
service: DockerService,
binding: DockerServiceBinding,
) {
openCreateWizard(service, binding);
}
async function createMissingServiceProxies() {
await run(async () => {
for (const entry of serviceEntries.value) {
if (entry.proxy) continue;
await api.createProxy(
draftForService(
entry.service,
entry.binding,
templates.value.find((template) => template.protocol === "http")?.code,
),
);
}
await Promise.all([refreshProxies(false), refreshServicesRequest(false)]);
const first = proxies.value[0];
if (first) {
selectedPort.value = first.proxy_port;
hydrateDraft(first);
}
}, "Missing proxies created");
}
async function saveProxy() {
if (!selectedProxy.value && routeState.value.proxyPortReservedMessage) {
setError(routeState.value.proxyPortReservedMessage, "wizard");
return;
}
let persisted = false;
await run(async () => {
const payload = cleanDraft();
const saved = selectedProxy.value
? await api.updateProxy(selectedProxy.value.proxy_port, {
name: payload.name,
code: payload.code,
mode: payload.mode,
mode_spec: payload.mode_spec,
protocol: payload.protocol,
listen_host: payload.listen_host,
mitm_options: payload.mitm_options,
healthcheck: payload.healthcheck,
service: payload.service,
})
: await api.createProxy(payload);
await Promise.all([refreshProxies(false), refreshServicesRequest(false)]);
selectedPort.value = saved.proxy_port;
const refreshed = proxies.value.find((proxy) => proxy.proxy_port === saved.proxy_port);
hydrateDraft(refreshed ?? saved);
persisted = true;
}, "Proxy saved", "wizard");
if (persisted) {
wizardSeed.value = null;
wizardOpen.value = false;
wizardStep.value = 0;
}
}
async function saveScript() {
const proxy = selectedProxy.value;
if (!proxy) return;
await run(async () => {
const saved = await api.updateProxy(proxy.proxy_port, {
code: draft.code,
});
syncLoadedCode(saved.code);
await refreshProxies(false);
}, "Script saved", "workspace");
}
async function removeProxy() {
const proxy = selectedProxy.value;
if (!proxy) return;
await run(async () => {
await api.deleteProxy(proxy.proxy_port);
selectedPort.value = null;
loadedPort.value = null;
await Promise.all([refreshProxies(false), refreshServicesRequest(false)]);
}, "Proxy deleted", "workspace");
}
async function runtimeAction(kind: RuntimeAction) {
const proxy = selectedProxy.value;
if (!proxy) return;
if (
(kind === "start" || kind === "restart") &&
routeState.value.proxyPortReservedMessage
) {
setError(routeState.value.proxyPortReservedMessage, "workspace");
return;
}
await run(async () => {
const port = proxy.proxy_port;
const updated =
kind === "start"
? await api.start(port)
: kind === "stop"
? await api.stop(port)
: await api.restart(port);
await refreshProxies(false);
selectedPort.value = updated.proxy_port;
const refreshed = proxies.value.find((item) => item.proxy_port === updated.proxy_port);
hydrateDraft(refreshed ?? updated);
}, `Proxy ${kind} requested`, "workspace");
}
async function checkHealth() {
const proxy = selectedProxy.value;
if (!proxy) return;
await run(async () => {
const snapshot = await api.health(proxy.proxy_port);
health.value = snapshot;
pushHealthSample(snapshot);
}, undefined, "workspace");
}
function openHealthModal() {
healthModalOpen.value = true;
}
function closeHealthModal() {
healthModalOpen.value = false;
}
function openDiscovery() {
clearError();
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) {
setError("Custom template name is required", "workspace");
return;
}
const id =
name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "") ||
`template-${Date.now()}`;
await run(async () => {
await api.createTemplate({
id,
name,
description: `Saved from ${draft.name} (${draft.mode} / ${draft.protocol}).`,
category: templateProtocolForDraft(),
protocol: templateProtocolForDraft(),
family: "custom",
tags: [],
action: "custom",
summary: `Saved custom template from ${draft.name}.`,
behavior: "Applies the current proxy script exactly as saved by the operator.",
code: draft.code,
source_url: null,
});
await refreshTemplates(false);
customTemplateName.value = "";
templateProtocolFilter.value = templateProtocolForDraft();
selectedTemplateId.value = id;
}, "Custom template saved", "workspace");
}
async function removeTemplate(id: string) {
await run(async () => {
await api.deleteTemplate(id);
await refreshTemplates(false);
}, "Custom template deleted", "workspace");
}
function applyService(
service: DockerService,
port: number,
bindHost: string | null = null,
) {
const shouldFollowServicePort =
!selectedProxy.value || draft.proxy_port === draft.service.port;
draft.service.name = service.name;
draft.service.folder = service.folder;
draft.service.port = port;
draft.service.bind_host = bindHost;
draft.service.host = "127.0.0.1";
draft.listen_host = bindHost ?? "0.0.0.0";
if (draft.mode === "reverse" && !draft.mode_spec && shouldFollowServicePort) {
draft.proxy_port = port;
}
}
function applyTemplate(template: ProxyTemplate) {
if (
draft.code.trim() &&
draft.code.trim() !== template.code.trim() &&
typeof window !== "undefined" &&
!window.confirm(
"Applying this template will overwrite your current proxy script. Continue?",
)
) {
return;
}
selectedTemplateId.value = template.id;
draft.code = template.code;
showNotice(`Loaded ${template.name}.`, 1400);
}
function selectTemplate(templateId: string) {
selectedTemplateId.value = templateId;
}
function setOption(option: MitmOption) {
selectedOptionName.value = option.name;
if (!(option.name in draft.mitm_options)) {
draft.mitm_options[option.name] = defaultFor(option);
}
}
function selectOption(optionName: string) {
selectedOptionName.value = optionName;
}
function removeOption(name: string) {
delete draft.mitm_options[name];
}
function updateOption(name: string, value: string, option?: MitmOption) {
if (option?.type === "bool") {
draft.mitm_options[name] = value === "true";
return;
}
if (option?.type === "int" || option?.type === "optional int") {
draft.mitm_options[name] = value === "" ? null : Number(value);
return;
}
if (option?.type === "sequence of str") {
draft.mitm_options[name] = value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
return;
}
draft.mitm_options[name] = value;
}
function matchProxyPortToService() {
draft.proxy_port = draft.service.port;
}
function toggleOptionScope(scope: OptionScope) {
optionScope.value = scope;
}
function toggleLogsPaused() {
logsPaused.value = !logsPaused.value;
}
function clearLogs() {
logs.value = "";
logsCleared.value = true;
}
function cleanDraft(): ProxyDraft {
return draftPayload(draft);
}
function hydrateDraft(proxy: ProxyDocument) {
const nextDraft = draftPayload(proxy);
Object.assign(draft, nextDraft);
loadedPort.value = proxy.proxy_port;
loadedDraftJson.value = serializeDraft(nextDraft);
loadedCode.value = nextDraft.code;
health.value = null;
logs.value = "";
}
function syncLoadedCode(code: string) {
loadedCode.value = code;
const baseline = loadedDraftJson.value
? (JSON.parse(loadedDraftJson.value) as ProxyDraft)
: cleanDraft();
baseline.code = code;
loadedDraftJson.value = serializeDraft(baseline);
}
function nextPort() {
const used = new Set(proxies.value.map((proxy) => proxy.proxy_port));
let port = 8080;
while (used.has(port)) port += 1;
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";
if (selectedProxy.value) return "workspace";
return "discovery";
}
function clearError(surface?: ErrorSurface) {
if (surface) {
errors[surface] = "";
return;
}
for (const key of Object.keys(errors) as ErrorSurface[]) {
errors[key] = "";
}
}
function setError(source: unknown, surface: ErrorSurface) {
errors[surface] = source instanceof Error ? source.message : String(source);
}
async function run(
task: () => Promise<void>,
success?: string,
surface: ErrorSurface = activeSurface(),
) {
loading.value = true;
clearError(surface);
clearNotice();
try {
await task();
if (success) showNotice(success);
} catch (err) {
if (err instanceof ApiError && err.status === 401) {
clearSession("Session expired. Sign in again.");
return;
}
setError(err, surface);
} finally {
loading.value = false;
}
}
function showNotice(message: string, timeout = 2600) {
notice.value = message;
if (typeof window === "undefined") return;
if (noticeTimer !== null) window.clearTimeout(noticeTimer);
noticeTimer = window.setTimeout(() => {
notice.value = "";
noticeTimer = null;
}, timeout);
}
function clearNotice() {
notice.value = "";
if (typeof window === "undefined" || noticeTimer === null) return;
window.clearTimeout(noticeTimer);
noticeTimer = null;
}
function clearSession(message = "") {
stopHealthStream();
stopLogStream();
setToken(null);
token.value = null;
proxies.value = [];
services.value = [];
options.value = [];
templates.value = [];
backendPort.value = null;
selectedPort.value = null;
loadedPort.value = null;
health.value = null;
logs.value = "";
logFilter.value = "";
logsCleared.value = false;
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);
loadedCode.value = draft.code;
authMessage.value = message;
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,
applyTemplate,
applyService,
actionState,
authMessage,
checkHealth,
clearLogs,
closeWizard,
codeLineCount,
canSaveScript,
canSaveProxy,
filteredLogs,
logFilter,
logsPaused,
createMissingServiceProxies,
createProxyFromService,
customTemplateName,
draft,
error,
templateSearch,
templateProtocolFilter,
filteredTemplates,
filteredOptions,
health,
healthHistory,
healthModalOpen,
discoveryError,
loginError,
loadLogs,
loading,
logs,
newProxy,
notice,
openCreateWizard,
openDiscovery,
openEditWizard,
openHealthModal,
optionCount,
optionScope,
optionSearch,
password,
previousWizardStep,
proxies,
refreshServices,
refreshAll,
removeTemplate,
removeOption,
removeProxy,
routeState,
runtimeStatus,
runtimeAction,
saveCurrentAsTemplate,
saveProxy,
saveScript,
selectTemplate,
selectedTemplate,
selectedOption,
selectedPort,
selectedProxy,
serviceGroups,
selectOption,
serviceEntries,
services,
templatesLoading,
proxiesLoading,
scriptDirty,
setOption,
signIn,
signOut,
closeHealthModal,
matchProxyPortToService,
missingServiceCount,
nextWizardStep,
toggleOptionScope,
toggleLogsPaused,
token,
updateOption,
username,
wizardMode,
wizardError,
wizardOpen,
wizardStep,
wizardStepTitle,
wizardTitle,
workspaceError,
optionsLoading,
canGoBackWizard,
canGoNextWizard,
scanRoot,
servicesLoading,
});
}
export type ControlPlane = ReturnType<typeof useControlPlane>;
export const controlPlaneKey: InjectionKey<ControlPlane> = Symbol("control-plane");
export function useControlPlaneContext() {
const context = inject(controlPlaneKey);
if (!context) {
throw new Error("Control plane context is not available");
}
return context;
}