Files
MapMil/centers/analytics/frontend/src/views/VpnView.vue
T
gitrusprusandCursor 5811ecb134 Add VPN admin tab with subscription proxy via cp-vpn for selected sources.
Persist settings in CA, expose /admin/vpn and /internal/vpn, and route Telegram (and optional web/nlp) traffic through mihomo SOCKS when enabled.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 23:41:33 +03:00

279 lines
8.2 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { fetchVpnSettings, updateVpnSettings } from "../api/admin";
import type { VpnMode, VpnSettings } from "../types/admin";
const loading = ref(true);
const submitting = ref(false);
const error = ref("");
const success = ref("");
const settings = ref<VpnSettings | null>(null);
const form = ref({
enabled: false,
mode: "subscription" as VpnMode,
subscription_url: "",
clear_subscription_url: false,
subscription_interval_seconds: 3600,
host: "",
port: 1080 as number | null,
username: "",
password: "",
clear_password: false,
proxied_source_types: [] as string[],
});
const sourceLabels: Record<string, string> = {
telegram: "Telegram",
crawl4ai: "Crawl4AI (web)",
viina: "VIINA (nlp)",
};
const isSubscription = computed(() => form.value.mode === "subscription");
const isDirectProxy = computed(
() => form.value.mode === "socks5" || form.value.mode === "http",
);
function applySettings(data: VpnSettings) {
settings.value = data;
form.value = {
enabled: data.enabled,
mode: data.mode,
subscription_url: "",
clear_subscription_url: false,
subscription_interval_seconds: data.subscription_interval_seconds,
host: data.host ?? "",
port: data.port ?? 1080,
username: data.username ?? "",
password: "",
clear_password: false,
proxied_source_types: [...data.proxied_source_types],
};
}
async function load() {
loading.value = true;
error.value = "";
success.value = "";
try {
applySettings(await fetchVpnSettings());
} catch (err) {
error.value = err instanceof Error ? err.message : "Не удалось загрузить VPN";
} finally {
loading.value = false;
}
}
function toggleSource(sourceType: string, checked: boolean) {
const set = new Set(form.value.proxied_source_types);
if (checked) set.add(sourceType);
else set.delete(sourceType);
form.value.proxied_source_types = [...set];
}
async function handleSave() {
submitting.value = true;
error.value = "";
success.value = "";
try {
const payload: Parameters<typeof updateVpnSettings>[0] = {
enabled: form.value.enabled,
mode: form.value.mode,
subscription_interval_seconds: form.value.subscription_interval_seconds,
host: form.value.host.trim() || null,
port: form.value.port,
username: form.value.username.trim() || null,
proxied_source_types: form.value.proxied_source_types,
};
if (form.value.clear_subscription_url) {
payload.clear_subscription_url = true;
} else if (form.value.subscription_url.trim()) {
payload.subscription_url = form.value.subscription_url.trim();
}
if (form.value.clear_password) {
payload.clear_password = true;
} else if (form.value.password) {
payload.password = form.value.password;
}
applySettings(await updateVpnSettings(payload));
success.value = "Настройки VPN сохранены";
} catch (err) {
error.value = err instanceof Error ? err.message : "Не удалось сохранить";
} finally {
submitting.value = false;
}
}
onMounted(load);
</script>
<template>
<div class="page">
<h2 class="page-heading">VPN</h2>
<p class="intro">
Проксирование трафика выбранных источников парсинга. Режим
<strong>subscription</strong> поднимает SOCKS через сервис
<code>cp-vpn</code> (mihomo). Режимы SOCKS5/HTTP задают адрес напрямую для
воркеров. Без включённого VPN остаётся fallback из
<code>TELEGRAM_PROXY_*</code> в <code>.env</code>.
</p>
<section class="card">
<h3>Настройки <span v-if="loading" class="muted">(загрузка...)</span></h3>
<form v-if="settings" class="admin-form" @submit.prevent="handleSave">
<label class="checkbox-row">
<input v-model="form.enabled" type="checkbox" />
<span>Прокси включён</span>
</label>
<div class="form-row vpn-row">
<label>
Способ проксирования
<select v-model="form.mode">
<option value="subscription">Subscription URL</option>
<option value="socks5">SOCKS5</option>
<option value="http">HTTP</option>
</select>
</label>
</div>
<template v-if="isSubscription">
<label>
Адрес подписки (subscription URL)
<input
v-model="form.subscription_url"
type="url"
placeholder="https://sub.example.com/…"
autocomplete="off"
/>
</label>
<p v-if="settings.subscription_url_set" class="hint">
Сохранён:
<code>{{ settings.subscription_url_hint }}</code>
— оставьте поле пустым, чтобы не менять.
</p>
<label class="checkbox-row">
<input v-model="form.clear_subscription_url" type="checkbox" />
<span>Очистить сохранённый URL</span>
</label>
<label>
Интервал обновления подписки (сек)
<input
v-model.number="form.subscription_interval_seconds"
type="number"
min="60"
max="86400"
/>
</label>
</template>
<template v-if="isDirectProxy">
<div class="form-row">
<label>
Host
<input v-model="form.host" type="text" placeholder="127.0.0.1" />
</label>
<label>
Port
<input v-model.number="form.port" type="number" min="1" max="65535" />
</label>
<label>
Username
<input v-model="form.username" type="text" autocomplete="off" />
</label>
<label>
Password
<input
v-model="form.password"
type="password"
autocomplete="new-password"
:placeholder="settings.password_set ? '•••• (оставьте пустым)' : ''"
/>
</label>
</div>
<label v-if="settings.password_set" class="checkbox-row">
<input v-model="form.clear_password" type="checkbox" />
<span>Очистить пароль</span>
</label>
</template>
<fieldset class="sources">
<legend>Источники через прокси</legend>
<label
v-for="st in settings.available_source_types"
:key="st"
class="checkbox-row"
>
<input
type="checkbox"
:checked="form.proxied_source_types.includes(st)"
@change="
toggleSource(st, ($event.target as HTMLInputElement).checked)
"
/>
<span>{{ sourceLabels[st] || st }} <code>({{ st }})</code></span>
</label>
</fieldset>
<div class="form-actions">
<button type="submit" class="btn btn-primary" :disabled="submitting || loading">
{{ submitting ? "Сохранение..." : "Сохранить" }}
</button>
</div>
<p v-if="error" class="form-error">{{ error }}</p>
<p v-if="success" class="form-success">{{ success }}</p>
</form>
<p v-else-if="error" class="form-error">{{ error }}</p>
</section>
</div>
</template>
<style scoped>
.intro {
margin: 0 0 1rem;
font-size: 0.9rem;
color: #555;
line-height: 1.5;
}
.checkbox-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.5rem 0 1rem;
}
.vpn-row {
margin-bottom: 0.75rem;
}
.hint {
margin: -0.5rem 0 0.75rem;
font-size: 0.85rem;
color: #666;
}
.sources {
border: 1px solid #e5e5e5;
border-radius: 6px;
padding: 0.75rem 1rem 0.25rem;
margin: 0.5rem 0 1rem;
}
.sources legend {
padding: 0 0.35rem;
font-size: 0.9rem;
color: #444;
}
.form-success {
margin: 0.75rem 0 0;
color: #1a7f37;
font-size: 0.9rem;
}
</style>