feat: enhance service discovery and proxy management
This commit is contained in:
@@ -7,19 +7,38 @@ import {
|
||||
watch,
|
||||
type InjectionKey,
|
||||
} from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
import { api, getToken, login, logout } from "../api";
|
||||
import { ApiError, api, getToken, login, logout, setToken } from "../api";
|
||||
import type {
|
||||
AddonExample,
|
||||
DockerService,
|
||||
DockerServiceBinding,
|
||||
HealthResult,
|
||||
MitmOption,
|
||||
ProxyDocument,
|
||||
ProxyDraft,
|
||||
} from "../types";
|
||||
|
||||
const editorStorageKey = "theplug.editor.open";
|
||||
const toolStorageKey = "theplug.tool.view";
|
||||
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 const modes = [
|
||||
"reverse",
|
||||
@@ -46,7 +65,7 @@ export const protocols = [
|
||||
];
|
||||
|
||||
export type RuntimeAction = "start" | "stop" | "restart";
|
||||
export type ToolView = "options" | "examples" | "logs";
|
||||
export type OptionScope = "focused" | "all";
|
||||
|
||||
export function createDefaultDraft(port = 8080, code?: string): ProxyDraft {
|
||||
return {
|
||||
@@ -82,6 +101,7 @@ def request(flow: http.HTTPFlow) -> None:
|
||||
service: {
|
||||
host: "127.0.0.1",
|
||||
port: 9000,
|
||||
bind_host: null,
|
||||
name: null,
|
||||
folder: null,
|
||||
upstream_port: null,
|
||||
@@ -99,13 +119,48 @@ function writeSession(key: string, value: string) {
|
||||
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 error = ref("");
|
||||
const notice = ref("");
|
||||
const loading = ref(false);
|
||||
const servicesLoading = ref(false);
|
||||
const proxies = ref<ProxyDocument[]>([]);
|
||||
const services = ref<DockerService[]>([]);
|
||||
const options = ref<MitmOption[]>([]);
|
||||
@@ -113,21 +168,153 @@ export function useControlPlane() {
|
||||
const selectedPort = ref<number | null>(null);
|
||||
const health = ref<HealthResult | null>(null);
|
||||
const logs = ref("");
|
||||
const logFilter = ref("");
|
||||
const logsPaused = ref(false);
|
||||
const optionSearch = ref("");
|
||||
const exampleSearch = ref("");
|
||||
const customExampleName = ref("");
|
||||
const editorOpen = ref(readSession(editorStorageKey, "0") === "1");
|
||||
const toolView = ref<ToolView>(
|
||||
readSession(toolStorageKey, "options") as ToolView,
|
||||
const scanRoot = ref(readSession(scanRootStorageKey, "/root"));
|
||||
const optionScope = ref<OptionScope>(
|
||||
readSession(optionScopeStorageKey, "focused") as OptionScope,
|
||||
);
|
||||
const selectedOptionName = ref("");
|
||||
const selectedExampleId = ref("");
|
||||
const draft = reactive<ProxyDraft>(createDefaultDraft());
|
||||
const loadedPort = ref<number | null>(null);
|
||||
const wizardOpen = ref(false);
|
||||
const wizardMode = ref<"create" | "edit">("create");
|
||||
const wizardStep = ref(0);
|
||||
let noticeTimer: number | null = null;
|
||||
let logPollTimer: 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,
|
||||
canStop: !loading.value && Boolean(selectedProxy.value) && isRunning.value,
|
||||
canRestart: !loading.value && Boolean(selectedProxy.value) && isRunning.value,
|
||||
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 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 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") {
|
||||
prepareDraftForCreate();
|
||||
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 routeState = computed(() => {
|
||||
const livePort =
|
||||
selectedProxy.value?.service.upstream_port ?? draft.service.upstream_port ?? null;
|
||||
@@ -136,27 +323,43 @@ export function useControlPlane() {
|
||||
const remapped = livePort !== null && livePort !== draft.service.port;
|
||||
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;
|
||||
|
||||
let startNotice = "Save stores configuration only. Start applies runtime routing.";
|
||||
if (reverseManaged && dockerManaged) {
|
||||
let startNotice: string | null = null;
|
||||
if (takeoverReady) {
|
||||
startNotice =
|
||||
`Starting this reverse proxy will move the service from :${draft.service.port} ` +
|
||||
`to a temporary upstream port and bind mitmdump on :${draft.proxy_port}.`;
|
||||
`Start will move the real service from :${draft.service.port} to a temporary upstream port and put mitmdump on :${draft.proxy_port}.`;
|
||||
} else if (takeoverMismatch) {
|
||||
startNotice =
|
||||
`Start will move the real service away from :${draft.service.port}, but this proxy is listening on :${draft.proxy_port}. Set the proxy port to :${draft.service.port} if you want mitmdump to replace the original route.`;
|
||||
} else if (draft.mode_spec) {
|
||||
startNotice =
|
||||
"This proxy uses a raw mode specification. Start will follow the custom mitmdump mode exactly as configured.";
|
||||
"Raw mode spec is enabled. Start will use the exact mitmdump mode string you configured.";
|
||||
} else if (draft.mode !== "reverse") {
|
||||
startNotice =
|
||||
"This mode does not use Docker port remapping. Start only launches the configured mitmdump listener.";
|
||||
"This mode does not remap the Docker service port. Start only launches the configured mitmdump listener.";
|
||||
}
|
||||
|
||||
let liveNotice = "No runtime remap is active.";
|
||||
let liveNotice: string | null = null;
|
||||
if (remapped) {
|
||||
liveNotice = `Live upstream is currently ${liveTarget}. Stop restores :${draft.service.port}.`;
|
||||
} else if (reverseManaged && dockerManaged) {
|
||||
liveNotice = "The original service port stays unchanged until Start is executed.";
|
||||
} else if (takeoverMismatch) {
|
||||
liveNotice = "The service will be moved away on Start, but the original route is not taken over unless the proxy port matches the service port.";
|
||||
}
|
||||
|
||||
const summaryHint = takeoverMismatch
|
||||
? startNotice
|
||||
: remapped
|
||||
? liveNotice
|
||||
: draft.mode_spec
|
||||
? startNotice
|
||||
: draft.mode !== "reverse"
|
||||
? startNotice
|
||||
: null;
|
||||
|
||||
return {
|
||||
configuredListen: `${draft.listen_host}:${draft.proxy_port}`,
|
||||
configuredTarget,
|
||||
@@ -167,26 +370,35 @@ export function useControlPlane() {
|
||||
reverseManaged,
|
||||
remapped,
|
||||
livePort,
|
||||
takeoverReady,
|
||||
takeoverMismatch,
|
||||
startNotice,
|
||||
liveNotice,
|
||||
summaryHint,
|
||||
};
|
||||
});
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
const query = optionSearch.value.toLowerCase().trim();
|
||||
const available = options.value.filter((option) => !option.reserved);
|
||||
const filtered = available
|
||||
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 : 36);
|
||||
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(
|
||||
() =>
|
||||
@@ -226,19 +438,43 @@ export function useControlPlane() {
|
||||
|
||||
const codeLineCount = computed(() => draft.code.split("\n").length);
|
||||
|
||||
watch(selectedProxy, (proxy) => {
|
||||
if (!proxy) return;
|
||||
Object.assign(draft, structuredClone(proxy));
|
||||
health.value = null;
|
||||
logs.value = "";
|
||||
watch(
|
||||
[selectedPort, proxies],
|
||||
([port]) => {
|
||||
if (port === null || port === loadedPort.value) return;
|
||||
const proxy = proxies.value.find((item) => item.proxy_port === port);
|
||||
if (!proxy) return;
|
||||
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(editorOpen, (value) => {
|
||||
writeSession(editorStorageKey, value ? "1" : "0");
|
||||
});
|
||||
|
||||
watch(toolView, (value) => {
|
||||
writeSession(toolStorageKey, value);
|
||||
watch(scanRoot, (value) => {
|
||||
writeSession(scanRootStorageKey, value);
|
||||
});
|
||||
|
||||
watch(filteredOptions, (items) => {
|
||||
@@ -253,60 +489,196 @@ export function useControlPlane() {
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
watch(selectedProxy, (proxy) => {
|
||||
if (!proxy) return;
|
||||
if (!wizardOpen.value) {
|
||||
hydrateDraft(proxy);
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
[selectedPort, token, logsPaused],
|
||||
([port, authToken, paused]) => {
|
||||
stopLogPolling();
|
||||
if (!authToken || port === null || paused) return;
|
||||
void syncLogs(true);
|
||||
if (typeof window === "undefined") return;
|
||||
logPollTimer = window.setInterval(() => {
|
||||
void syncLogs(true);
|
||||
}, 2000);
|
||||
},
|
||||
{ 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");
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
await logout();
|
||||
token.value = null;
|
||||
proxies.value = [];
|
||||
if (token.value) {
|
||||
await logout();
|
||||
}
|
||||
clearSession();
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
await run(async () => {
|
||||
const [proxyList, serviceList, optionList, exampleList] = await Promise.all([
|
||||
api.proxies(),
|
||||
api.services(),
|
||||
api.options(),
|
||||
api.examples(),
|
||||
]);
|
||||
proxies.value = proxyList;
|
||||
services.value = serviceList;
|
||||
options.value = optionList;
|
||||
examples.value = exampleList;
|
||||
if (!selectedPort.value && proxyList[0]) {
|
||||
selectedPort.value = proxyList[0].proxy_port;
|
||||
servicesLoading.value = true;
|
||||
try {
|
||||
const [proxyList, serviceResponse, optionList, exampleList] =
|
||||
await Promise.all([
|
||||
api.proxies(),
|
||||
api.services(scanRoot.value.trim() || undefined),
|
||||
api.options(),
|
||||
api.examples(),
|
||||
]);
|
||||
proxies.value = proxyList;
|
||||
services.value = serviceResponse.services;
|
||||
scanRoot.value = serviceResponse.scan_root;
|
||||
options.value = optionList;
|
||||
examples.value = exampleList;
|
||||
if (!proxyList.some((proxy) => proxy.proxy_port === selectedPort.value)) {
|
||||
selectedPort.value = null;
|
||||
}
|
||||
} finally {
|
||||
servicesLoading.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function newProxy() {
|
||||
async function refreshServices() {
|
||||
servicesLoading.value = true;
|
||||
error.value = "";
|
||||
try {
|
||||
const response = await api.services(scanRoot.value.trim() || undefined);
|
||||
scanRoot.value = response.scan_root;
|
||||
services.value = response.services;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
servicesLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function prepareDraftForCreate(announce = false) {
|
||||
selectedPort.value = null;
|
||||
loadedPort.value = null;
|
||||
health.value = null;
|
||||
logs.value = "";
|
||||
logFilter.value = "";
|
||||
Object.assign(
|
||||
draft,
|
||||
createDefaultDraft(nextPort(), examples.value[0]?.code ?? draft.code),
|
||||
);
|
||||
notice.value = "New proxy draft created. Save stores configuration only.";
|
||||
toolView.value = "options";
|
||||
if (announce) {
|
||||
notice.value = "Draft created.";
|
||||
}
|
||||
}
|
||||
|
||||
function newProxy() {
|
||||
prepareDraftForCreate(true);
|
||||
}
|
||||
|
||||
function openCreateWizard(
|
||||
service?: DockerService,
|
||||
binding?: DockerServiceBinding,
|
||||
) {
|
||||
prepareDraftForCreate();
|
||||
if (service && binding) {
|
||||
Object.assign(draft, draftForService(service, binding));
|
||||
}
|
||||
wizardMode.value = "create";
|
||||
wizardStep.value = 0;
|
||||
wizardOpen.value = true;
|
||||
void syncRouteState();
|
||||
}
|
||||
|
||||
function openEditWizard(proxy = selectedProxy.value) {
|
||||
if (!proxy) return;
|
||||
selectedPort.value = proxy.proxy_port;
|
||||
hydrateDraft(proxy);
|
||||
wizardMode.value = "edit";
|
||||
wizardStep.value = 0;
|
||||
wizardOpen.value = true;
|
||||
void syncRouteState();
|
||||
}
|
||||
|
||||
function closeWizard() {
|
||||
if (wizardMode.value === "edit" && selectedProxy.value) {
|
||||
hydrateDraft(selectedProxy.value);
|
||||
} else if (wizardMode.value === "create") {
|
||||
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));
|
||||
}
|
||||
await refreshAll();
|
||||
const first = proxies.value[0];
|
||||
if (first) {
|
||||
selectedPort.value = first.proxy_port;
|
||||
hydrateDraft(first);
|
||||
}
|
||||
}, "Missing proxies created");
|
||||
}
|
||||
|
||||
async function saveProxy() {
|
||||
let persisted = false;
|
||||
await run(async () => {
|
||||
const payload = cleanDraft();
|
||||
const saved = selectedProxy.value
|
||||
? await api.updateProxy(selectedProxy.value.proxy_port, payload)
|
||||
? 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 refreshAll();
|
||||
selectedPort.value = saved.proxy_port;
|
||||
const refreshed = proxies.value.find((proxy) => proxy.proxy_port === saved.proxy_port);
|
||||
hydrateDraft(refreshed ?? saved);
|
||||
persisted = true;
|
||||
}, "Proxy saved");
|
||||
if (persisted) {
|
||||
wizardOpen.value = false;
|
||||
wizardStep.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeProxy() {
|
||||
@@ -315,6 +687,7 @@ export function useControlPlane() {
|
||||
await run(async () => {
|
||||
await api.deleteProxy(proxy.proxy_port);
|
||||
selectedPort.value = null;
|
||||
loadedPort.value = null;
|
||||
await refreshAll();
|
||||
}, "Proxy deleted");
|
||||
}
|
||||
@@ -330,8 +703,10 @@ export function useControlPlane() {
|
||||
: kind === "stop"
|
||||
? await api.stop(port)
|
||||
: await api.restart(port);
|
||||
replaceProxy(updated);
|
||||
await refreshAll();
|
||||
selectedPort.value = updated.proxy_port;
|
||||
const refreshed = proxies.value.find((item) => item.proxy_port === updated.proxy_port);
|
||||
hydrateDraft(refreshed ?? updated);
|
||||
}, `Proxy ${kind} requested`);
|
||||
}
|
||||
|
||||
@@ -344,11 +719,19 @@ export function useControlPlane() {
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
await syncLogs(false);
|
||||
}
|
||||
|
||||
async function syncLogs(silent = false) {
|
||||
const proxy = selectedProxy.value;
|
||||
if (!proxy) return;
|
||||
await run(async () => {
|
||||
try {
|
||||
logs.value = (await api.logs(proxy.proxy_port)).log;
|
||||
});
|
||||
} catch (err) {
|
||||
if (!silent) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCurrentAsExample() {
|
||||
@@ -364,7 +747,7 @@ export function useControlPlane() {
|
||||
await api.createExample({
|
||||
id,
|
||||
name,
|
||||
description: `Captured from ${draft.mode} ${draft.protocol} proxy ${draft.name}.`,
|
||||
description: `Saved from ${draft.name} (${draft.mode} / ${draft.protocol}).`,
|
||||
category: "custom",
|
||||
code: draft.code,
|
||||
source_url: null,
|
||||
@@ -383,19 +766,38 @@ export function useControlPlane() {
|
||||
}, "Custom example deleted");
|
||||
}
|
||||
|
||||
function applyService(service: DockerService, port: number) {
|
||||
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 applyExample(example: AddonExample) {
|
||||
if (
|
||||
draft.code.trim() &&
|
||||
draft.code.trim() !== example.code.trim() &&
|
||||
typeof window !== "undefined" &&
|
||||
!window.confirm(
|
||||
"Applying this template will overwrite your current proxy script. Continue?",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
selectedExampleId.value = example.id;
|
||||
draft.code = example.code;
|
||||
notice.value = editorOpen.value
|
||||
? `Loaded example: ${example.name}`
|
||||
: `Loaded example: ${example.name}. Open the editor to review or modify the code.`;
|
||||
notice.value = `Loaded ${example.name}.`;
|
||||
}
|
||||
|
||||
function selectExample(exampleId: string) {
|
||||
@@ -436,12 +838,20 @@ export function useControlPlane() {
|
||||
draft.mitm_options[name] = value;
|
||||
}
|
||||
|
||||
function toggleEditor(force?: boolean) {
|
||||
editorOpen.value = typeof force === "boolean" ? force : !editorOpen.value;
|
||||
function matchProxyPortToService() {
|
||||
draft.proxy_port = draft.service.port;
|
||||
}
|
||||
|
||||
function setToolView(view: ToolView) {
|
||||
toolView.value = view;
|
||||
function toggleOptionScope(scope: OptionScope) {
|
||||
optionScope.value = scope;
|
||||
}
|
||||
|
||||
function toggleLogsPaused() {
|
||||
logsPaused.value = !logsPaused.value;
|
||||
}
|
||||
|
||||
function clearLogs() {
|
||||
logs.value = "";
|
||||
}
|
||||
|
||||
function cleanDraft(): ProxyDraft {
|
||||
@@ -463,6 +873,7 @@ export function useControlPlane() {
|
||||
service: {
|
||||
host: draft.service.host,
|
||||
port: draft.service.port,
|
||||
bind_host: draft.service.bind_host || null,
|
||||
name: draft.service.name || null,
|
||||
folder: draft.service.folder || null,
|
||||
upstream_port: draft.service.upstream_port || null,
|
||||
@@ -471,12 +882,35 @@ export function useControlPlane() {
|
||||
) as ProxyDraft;
|
||||
}
|
||||
|
||||
function replaceProxy(proxy: ProxyDocument) {
|
||||
const index = proxies.value.findIndex(
|
||||
(item) => item.proxy_port === proxy.proxy_port,
|
||||
);
|
||||
if (index === -1) proxies.value.push(proxy);
|
||||
else proxies.value[index] = proxy;
|
||||
function hydrateDraft(proxy: ProxyDocument) {
|
||||
Object.assign(draft, JSON.parse(JSON.stringify(proxy)) as ProxyDraft);
|
||||
loadedPort.value = proxy.proxy_port;
|
||||
health.value = null;
|
||||
logs.value = "";
|
||||
}
|
||||
|
||||
function bindingsFor(service: DockerService) {
|
||||
return service.bindings?.length
|
||||
? service.bindings
|
||||
: service.ports.map((published) => ({ published, host: null }));
|
||||
}
|
||||
|
||||
function draftForService(service: DockerService, binding: DockerServiceBinding) {
|
||||
const template = createDefaultDraft(binding.published, examples.value[0]?.code);
|
||||
const portSuffix = bindingsFor(service).length > 1 ? `-${binding.published}` : "";
|
||||
return {
|
||||
...template,
|
||||
name: `${service.name}${portSuffix}`,
|
||||
listen_host: binding.host ?? "0.0.0.0",
|
||||
service: {
|
||||
host: "127.0.0.1",
|
||||
port: binding.published,
|
||||
bind_host: binding.host ?? null,
|
||||
name: service.name,
|
||||
folder: service.folder,
|
||||
upstream_port: null,
|
||||
},
|
||||
} satisfies ProxyDraft;
|
||||
}
|
||||
|
||||
function nextPort() {
|
||||
@@ -497,26 +931,83 @@ export function useControlPlane() {
|
||||
async function run(task: () => Promise<void>, success?: string) {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
notice.value = "";
|
||||
clearNotice();
|
||||
try {
|
||||
await task();
|
||||
if (success) notice.value = success;
|
||||
if (success) showNotice(success);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status === 401) {
|
||||
clearSession("Session expired. Sign in again.");
|
||||
return;
|
||||
}
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
} 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 = "") {
|
||||
stopLogPolling();
|
||||
setToken(null);
|
||||
token.value = null;
|
||||
proxies.value = [];
|
||||
services.value = [];
|
||||
options.value = [];
|
||||
examples.value = [];
|
||||
selectedPort.value = null;
|
||||
loadedPort.value = null;
|
||||
health.value = null;
|
||||
logs.value = "";
|
||||
logFilter.value = "";
|
||||
wizardOpen.value = false;
|
||||
wizardStep.value = 0;
|
||||
wizardMode.value = "create";
|
||||
Object.assign(draft, createDefaultDraft());
|
||||
authMessage.value = message;
|
||||
void router.replace({ path: "/proxies", query: {} });
|
||||
}
|
||||
|
||||
function stopLogPolling() {
|
||||
if (typeof window === "undefined" || logPollTimer === null) return;
|
||||
window.clearInterval(logPollTimer);
|
||||
logPollTimer = null;
|
||||
}
|
||||
|
||||
return proxyRefs({
|
||||
activeOptionCount,
|
||||
activeOptions,
|
||||
applyExample,
|
||||
applyService,
|
||||
actionState,
|
||||
authMessage,
|
||||
checkHealth,
|
||||
clearLogs,
|
||||
closeWizard,
|
||||
codeLineCount,
|
||||
filteredLogs,
|
||||
logFilter,
|
||||
logsPaused,
|
||||
createMissingServiceProxies,
|
||||
createProxyFromService,
|
||||
customExampleName,
|
||||
draft,
|
||||
editorOpen,
|
||||
error,
|
||||
exampleSearch,
|
||||
filteredExamples,
|
||||
@@ -527,15 +1018,21 @@ export function useControlPlane() {
|
||||
logs,
|
||||
newProxy,
|
||||
notice,
|
||||
openCreateWizard,
|
||||
openEditWizard,
|
||||
optionCount,
|
||||
optionScope,
|
||||
optionSearch,
|
||||
password,
|
||||
previousWizardStep,
|
||||
proxies,
|
||||
refreshServices,
|
||||
refreshAll,
|
||||
removeExample,
|
||||
removeOption,
|
||||
removeProxy,
|
||||
routeState,
|
||||
runtimeStatus,
|
||||
runtimeAction,
|
||||
saveCurrentAsExample,
|
||||
saveProxy,
|
||||
@@ -545,16 +1042,28 @@ export function useControlPlane() {
|
||||
selectedPort,
|
||||
selectedProxy,
|
||||
selectOption,
|
||||
serviceEntries,
|
||||
services,
|
||||
setOption,
|
||||
setToolView,
|
||||
signIn,
|
||||
signOut,
|
||||
toggleEditor,
|
||||
matchProxyPortToService,
|
||||
missingServiceCount,
|
||||
nextWizardStep,
|
||||
toggleOptionScope,
|
||||
toggleLogsPaused,
|
||||
token,
|
||||
toolView,
|
||||
updateOption,
|
||||
username,
|
||||
wizardMode,
|
||||
wizardOpen,
|
||||
wizardStep,
|
||||
wizardStepTitle,
|
||||
wizardTitle,
|
||||
canGoBackWizard,
|
||||
canGoNextWizard,
|
||||
scanRoot,
|
||||
servicesLoading,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user