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, expected: Record, ) { 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>({ 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([]); const services = ref([]); const options = ref([]); const templates = ref([]); const backendPort = ref(null); const selectedPort = ref(null); const health = ref(null); const healthHistory = ref([]); const logs = ref(""); const logFilter = ref(""); const logsPaused = ref(false); const optionSearch = ref(""); const templateSearch = ref(""); const customTemplateName = ref(""); const templateProtocolFilter = ref("http"); const scanRoot = ref(readSession(scanRootStorageKey, "/root")); const optionScope = ref( readSession(optionScopeStorageKey, "focused") as OptionScope, ); const selectedOptionName = ref(""); const selectedTemplateId = ref(""); const draft = reactive(createDefaultDraft()); const loadedPort = ref(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 = { 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, 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, 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; export const controlPlaneKey: InjectionKey = Symbol("control-plane"); export function useControlPlaneContext() { const context = inject(controlPlaneKey); if (!context) { throw new Error("Control plane context is not available"); } return context; }