1459 lines
40 KiB
TypeScript
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;
|
|
}
|