feat: интеграция с VPS Tracker
Build, Test, and Push CFDM Docker Image / test (push) Failing after 3m36s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been skipped
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been skipped
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
Build, Test, and Push CFDM Docker Image / test (push) Failing after 3m36s
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been skipped
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been skipped
Build, Test, and Push CFDM Docker Image / create-release (push) Has been skipped
Исходящий sync bindings после updateConfig, настройки в app_settings, страница Интеграции, приём событий vps_down для DNS failover. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Vendored
+365
-161
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,8 @@ import { subdomainRoutes } from "./routes/subdomains.js";
|
||||
import { certificateRoutes } from "./routes/certificates.js";
|
||||
import { syncRoutes } from "./routes/sync.js";
|
||||
import { healthCheckRoutes } from "./routes/health-check.js";
|
||||
import { settingsRoutes } from "./routes/settings.js";
|
||||
import { integrationsVpsTrackerRoutes } from "./routes/integrations-vps-tracker.js";
|
||||
import * as certificateService from "./services/certificate-service.js";
|
||||
import * as healthCheckService from "./services/health-check-service.js";
|
||||
import * as serviceConfigService from "./services/service-config-service.js";
|
||||
@@ -58,6 +60,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
||||
|
||||
await app.register(healthRoutes);
|
||||
await app.register(authRoutes, { prefix: "/api/v1" });
|
||||
await app.register(integrationsVpsTrackerRoutes, { prefix: "/api/v1" });
|
||||
|
||||
await app.register(
|
||||
async (protectedApi) => {
|
||||
@@ -72,6 +75,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
||||
await protectedApi.register(certificateRoutes);
|
||||
await protectedApi.register(syncRoutes);
|
||||
await protectedApi.register(healthCheckRoutes);
|
||||
await protectedApi.register(settingsRoutes);
|
||||
},
|
||||
{ prefix: "/api/v1" },
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { healthCheck } from "../plugins/db.js";
|
||||
import { getAppSwitcher } from "@cfdm/db/settings-repo";
|
||||
import * as authService from "../services/auth.js";
|
||||
|
||||
export async function healthRoutes(app: FastifyInstance) {
|
||||
@@ -31,6 +32,10 @@ export async function healthRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
export async function authRoutes(app: FastifyInstance) {
|
||||
app.get("/settings/app-switcher", async (request) => {
|
||||
return getAppSwitcher(request.server.db);
|
||||
});
|
||||
|
||||
const loginSchema = z.object({
|
||||
username: z.string(),
|
||||
password: z.string(),
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { vpsTrackerEventSchema } from "@cfdm/shared";
|
||||
import { getAppSettingsSecrets } from "@cfdm/db/settings-repo";
|
||||
import * as vpsTrackerEvents from "../services/vps-tracker-events.js";
|
||||
|
||||
function verifyBearer(authHeader: string | undefined, expected: string): boolean {
|
||||
if (!authHeader?.startsWith("Bearer ")) return false;
|
||||
const token = authHeader.slice(7);
|
||||
if (!token || !expected) return false;
|
||||
const a = Buffer.from(token);
|
||||
const b = Buffer.from(expected);
|
||||
if (a.length !== b.length) return false;
|
||||
return timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
export async function integrationsVpsTrackerRoutes(app: FastifyInstance) {
|
||||
app.post("/integrations/vps-tracker/events", async (req, reply) => {
|
||||
const secrets = getAppSettingsSecrets(app.db);
|
||||
const token = secrets.vpsTrackerIntegrationToken;
|
||||
if (!token) {
|
||||
return reply.status(503).send({ error: "Integration not configured" });
|
||||
}
|
||||
if (!verifyBearer(req.headers.authorization, token)) {
|
||||
return reply.status(401).send({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
const parsed = vpsTrackerEventSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: parsed.error.flatten() });
|
||||
}
|
||||
|
||||
if (parsed.data.event !== "vps_down") {
|
||||
return { ok: true, reconciled: 0 };
|
||||
}
|
||||
|
||||
const reconciled = await vpsTrackerEvents.reconcileForVpsDown(
|
||||
app.db,
|
||||
app.cf,
|
||||
parsed.data,
|
||||
);
|
||||
return { ok: true, reconciled };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { appSettingsPatchSchema } from "@cfdm/shared";
|
||||
import {
|
||||
getAppSettings,
|
||||
updateAppSettings,
|
||||
} from "@cfdm/db/settings-repo";
|
||||
import { pingVpsTracker } from "../services/vps-tracker-sync.js";
|
||||
|
||||
export async function settingsRoutes(app: FastifyInstance) {
|
||||
app.get("/settings", async (request) => {
|
||||
return getAppSettings(request.server.db);
|
||||
});
|
||||
|
||||
app.patch("/settings", async (request) => {
|
||||
const body = appSettingsPatchSchema.parse(request.body);
|
||||
return updateAppSettings(request.server.db, body);
|
||||
});
|
||||
|
||||
app.post("/settings/vps-tracker/test", async (request) => {
|
||||
return pingVpsTracker(request.server.db);
|
||||
});
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import { AppError } from "../errors.js";
|
||||
import { isValidIpv4 } from "../lib/validators.js";
|
||||
import * as dnsService from "./dns-service.js";
|
||||
import * as domainService from "./domain-service.js";
|
||||
import { syncServiceToVpsTracker } from "./vps-tracker-sync.js";
|
||||
|
||||
export interface ServiceDomainInput {
|
||||
fqdn: string;
|
||||
@@ -1100,6 +1101,7 @@ export async function updateConfig(
|
||||
const keptBindingIds: number[] = [];
|
||||
let service = repos.getService(db, id);
|
||||
const pushDns = shouldPushDns(db, service);
|
||||
let removedBindingIds: number[] = [];
|
||||
|
||||
if (req.domains) {
|
||||
if (req.domains.length > 0) {
|
||||
@@ -1179,6 +1181,7 @@ export async function updateConfig(
|
||||
}
|
||||
|
||||
const removed = repos.bindingsToRemove(db, id, keptBindingIds);
|
||||
removedBindingIds = removed.map((binding) => binding.id);
|
||||
for (const binding of removed) {
|
||||
await cleanupBindingDns(
|
||||
db,
|
||||
@@ -1219,6 +1222,8 @@ export async function updateConfig(
|
||||
await syncGroupDomainForService(db, cf, id);
|
||||
}
|
||||
|
||||
void syncServiceToVpsTracker(db, id, removedBindingIds);
|
||||
|
||||
return buildView(db, id);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Db } from "@cfdm/db";
|
||||
import type { CloudflareClient } from "../lib/cf-client.js";
|
||||
import type { VpsTrackerEvent } from "@cfdm/shared";
|
||||
import * as repos from "@cfdm/db/repos";
|
||||
import { reconcileDnsForTarget } from "./service-config-service.js";
|
||||
|
||||
function collectIps(event: VpsTrackerEvent): Set<string> {
|
||||
const ips = new Set<string>();
|
||||
for (const v of event.vps) {
|
||||
if (v.ip?.trim()) ips.add(v.ip.trim());
|
||||
}
|
||||
return ips;
|
||||
}
|
||||
|
||||
function bindingUsesIps(
|
||||
db: Db,
|
||||
serviceId: number,
|
||||
bindingId: number,
|
||||
ips: Set<string>,
|
||||
): boolean {
|
||||
const targetIps = repos.listBindingIps(db, bindingId);
|
||||
const poolIps = repos.listServiceIps(db, serviceId);
|
||||
const effective = targetIps.length > 0 ? targetIps : poolIps;
|
||||
return effective.some((ip) => ips.has(ip));
|
||||
}
|
||||
|
||||
export async function reconcileForVpsDown(
|
||||
db: Db,
|
||||
cf: CloudflareClient,
|
||||
event: VpsTrackerEvent,
|
||||
): Promise<number> {
|
||||
const ips = collectIps(event);
|
||||
if (ips.size === 0) return 0;
|
||||
|
||||
const seen = new Set<string>();
|
||||
let reconciled = 0;
|
||||
|
||||
for (const service of repos.listServices(db)) {
|
||||
if (!service.enabled) continue;
|
||||
const bindings = repos.listBindingsByService(db, service.id);
|
||||
for (const binding of bindings) {
|
||||
if (!bindingUsesIps(db, service.id, binding.id, ips)) continue;
|
||||
const key = `binding:${binding.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
await reconcileDnsForTarget(db, cf, "binding", binding.id);
|
||||
reconciled += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return reconciled;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { CfdmBindingSyncItem } from "@cfdm/shared";
|
||||
import type { Db } from "@cfdm/db";
|
||||
import * as repos from "@cfdm/db/repos";
|
||||
import {
|
||||
getAppSettingsSecrets,
|
||||
touchVpsTrackerSync,
|
||||
} from "@cfdm/db/settings-repo";
|
||||
|
||||
function fqdnToDisplay(hostname: string, zoneName: string): string {
|
||||
if (hostname === "@" || !hostname.trim()) return zoneName;
|
||||
return `${hostname}.${zoneName}`;
|
||||
}
|
||||
|
||||
export function buildServiceSyncBindings(
|
||||
db: Db,
|
||||
serviceId: number,
|
||||
deletedBindingIds: number[] = [],
|
||||
): CfdmBindingSyncItem[] {
|
||||
const service = repos.getService(db, serviceId);
|
||||
const serviceIps = repos.listServiceIps(db, serviceId);
|
||||
const bindings = repos.listBindingsByService(db, serviceId);
|
||||
const items: CfdmBindingSyncItem[] = bindings.map((binding) => {
|
||||
const targetIps = repos.listBindingIps(db, binding.id);
|
||||
const ips =
|
||||
targetIps.length > 0
|
||||
? targetIps
|
||||
: serviceIps.length > 0
|
||||
? serviceIps
|
||||
: [];
|
||||
return {
|
||||
bindingId: binding.id,
|
||||
serviceId: service.id,
|
||||
serviceName: service.name,
|
||||
serviceSlug: service.slug,
|
||||
fqdn: fqdnToDisplay(binding.hostname, binding.zone_name),
|
||||
zoneName: binding.zone_name,
|
||||
hostname: binding.hostname,
|
||||
ips,
|
||||
};
|
||||
});
|
||||
|
||||
for (const bindingId of deletedBindingIds) {
|
||||
items.push({
|
||||
bindingId,
|
||||
serviceId: service.id,
|
||||
serviceName: service.name,
|
||||
serviceSlug: service.slug,
|
||||
fqdn: "",
|
||||
zoneName: "",
|
||||
hostname: "",
|
||||
ips: [],
|
||||
deleted: true,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export async function syncServiceToVpsTracker(
|
||||
db: Db,
|
||||
serviceId: number,
|
||||
deletedBindingIds: number[] = [],
|
||||
): Promise<void> {
|
||||
const config = getAppSettingsSecrets(db);
|
||||
if (!config.vpsTrackerSyncEnabled) return;
|
||||
|
||||
const baseUrl = config.vpsTrackerUrl.replace(/\/$/, "");
|
||||
const token = config.vpsTrackerIntegrationToken;
|
||||
if (!baseUrl || !token) return;
|
||||
|
||||
const bindings = buildServiceSyncBindings(db, serviceId, deletedBindingIds);
|
||||
if (bindings.length === 0) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/integrations/cfdm/sync-bindings`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ bindings }),
|
||||
});
|
||||
if (res.ok) {
|
||||
touchVpsTrackerSync(db);
|
||||
} else {
|
||||
console.warn(
|
||||
`VPS Tracker sync failed (${res.status}): ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"VPS Tracker sync error:",
|
||||
err instanceof Error ? err.message : err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function pingVpsTracker(db: Db): Promise<{
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
const config = getAppSettingsSecrets(db);
|
||||
const baseUrl = config.vpsTrackerUrl.replace(/\/$/, "");
|
||||
const token = config.vpsTrackerIntegrationToken;
|
||||
if (!baseUrl) return { ok: false, error: "Укажите URL VPS Tracker" };
|
||||
if (!token) return { ok: false, error: "Укажите integration token" };
|
||||
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/integrations/cfdm/ping`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `HTTP ${res.status}: ${await res.text()}`,
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : "Ошибка сети",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
FolderTreeIcon,
|
||||
ServerIcon,
|
||||
ShieldCheckIcon,
|
||||
SettingsIcon,
|
||||
} from 'lucide-react'
|
||||
import { AppSwitcher } from '@/components/app-switcher'
|
||||
import { NavUser } from '@/components/nav-user'
|
||||
@@ -30,6 +31,7 @@ const infrastructureNav = [
|
||||
const operationsNav = [
|
||||
{ to: '/services', label: 'Сервисы', icon: ServerIcon, exact: false },
|
||||
{ to: '/certificates', label: 'Сертификаты', icon: ShieldCheckIcon, exact: false },
|
||||
{ to: '/settings/integrations', label: 'Настройки', icon: SettingsIcon, exact: false },
|
||||
] as const
|
||||
|
||||
function NavSection({
|
||||
|
||||
@@ -16,13 +16,13 @@ import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react'
|
||||
import {
|
||||
APP_SWITCHER_ICONS,
|
||||
CURRENT_APP_ID,
|
||||
getAppSwitcherConfig,
|
||||
getCurrentApp,
|
||||
} from '@/lib/app-switcher-config'
|
||||
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
|
||||
|
||||
export function AppSwitcher() {
|
||||
const { isMobile } = useSidebar()
|
||||
const config = getAppSwitcherConfig()
|
||||
const { config, isLoading } = useAppSwitcherConfig()
|
||||
const current = getCurrentApp(config)
|
||||
const CurrentIcon = APP_SWITCHER_ICONS[current.icon]
|
||||
|
||||
@@ -58,7 +58,7 @@ export function AppSwitcher() {
|
||||
sideOffset={4}
|
||||
>
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{config.menuLabel}
|
||||
{isLoading ? 'Загрузка…' : config.menuLabel}
|
||||
</div>
|
||||
{config.apps.map((app) => {
|
||||
const Icon = APP_SWITCHER_ICONS[app.icon]
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useFieldArray, useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { PlusIcon, Trash2Icon } from 'lucide-react'
|
||||
import { appSwitcherConfigSchema, type AppSwitcherConfig } from '@cfdm/shared'
|
||||
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { AppCard, AppCardContent, AppCardDescription, AppCardHeader, AppCardTitle } from '@/components/app-card'
|
||||
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import { FormFieldSimple } from '@/components/form-field'
|
||||
import { SelectField } from '@/components/select-field'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { APP_SWITCHER_ICONS, type AppSwitcherIconName } from '@/lib/app-switcher-config'
|
||||
|
||||
const ICON_OPTIONS = (Object.keys(APP_SWITCHER_ICONS) as AppSwitcherIconName[]).map((icon) => ({
|
||||
value: icon,
|
||||
label: icon,
|
||||
}))
|
||||
|
||||
export type AppSwitcherFormValues = AppSwitcherConfig
|
||||
|
||||
interface AppSwitcherEditorProps {
|
||||
defaultValues: AppSwitcherFormValues
|
||||
onSave: (values: AppSwitcherFormValues) => void
|
||||
isSaving?: boolean
|
||||
}
|
||||
|
||||
export function AppSwitcherEditor({ defaultValues, onSave, isSaving }: AppSwitcherEditorProps) {
|
||||
const form = useForm({
|
||||
resolver: zodResolver(appSwitcherConfigSchema),
|
||||
defaultValues,
|
||||
})
|
||||
const { fields, append, remove } = useFieldArray({ control: form.control, name: 'apps' })
|
||||
|
||||
return (
|
||||
<AppCard>
|
||||
<AppCardHeader>
|
||||
<AppCardTitle>Связанные приложения</AppCardTitle>
|
||||
<AppCardDescription>URL для переключателя в sidebar</AppCardDescription>
|
||||
</AppCardHeader>
|
||||
<AppCardContent>
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(e) => void form.handleSubmit((values) => onSave(values))(e)}
|
||||
>
|
||||
<FieldGroup>
|
||||
<FormFieldSimple label="Заголовок меню" htmlFor="menu-label">
|
||||
<Input id="menu-label" {...form.register('menuLabel')} />
|
||||
</FormFieldSimple>
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="grid gap-3 rounded-lg border p-3 md:grid-cols-2">
|
||||
<FormFieldSimple label="ID" htmlFor={`app-id-${index}`}>
|
||||
<Input id={`app-id-${index}`} {...form.register(`apps.${index}.id`)} />
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple label="Название" htmlFor={`app-name-${index}`}>
|
||||
<Input id={`app-name-${index}`} {...form.register(`apps.${index}.name`)} />
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple label="URL" htmlFor={`app-url-${index}`} className="md:col-span-2">
|
||||
<Input id={`app-url-${index}`} {...form.register(`apps.${index}.url`)} />
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple label="Иконка" htmlFor={`app-icon-${index}`}>
|
||||
<SelectField
|
||||
triggerId={`app-icon-${index}`}
|
||||
value={form.watch(`apps.${index}.icon`)}
|
||||
onValueChange={(v: string | null) =>
|
||||
form.setValue(`apps.${index}.icon`, (v ?? 'server') as AppSwitcherIconName, {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
options={ICON_OPTIONS}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
<div className="flex items-end justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={fields.length <= 1}
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash2Icon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-fit"
|
||||
onClick={() =>
|
||||
append({
|
||||
id: `app-${fields.length + 1}`,
|
||||
name: 'Приложение',
|
||||
url: 'http://localhost:3000',
|
||||
icon: 'server',
|
||||
})
|
||||
}
|
||||
>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
Добавить приложение
|
||||
</Button>
|
||||
</FieldGroup>
|
||||
<LoadingButton type="submit" className="w-fit" isLoading={isSaving} disabled={!form.formState.isDirty}>
|
||||
Сохранить приложения
|
||||
</LoadingButton>
|
||||
</form>
|
||||
</AppCardContent>
|
||||
</AppCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useForm, Controller } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { AppCard, AppCardContent, AppCardDescription, AppCardHeader, AppCardTitle } from '@/components/app-card'
|
||||
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import { FormFieldSimple } from '@/components/form-field'
|
||||
import { SelectField } from '@/components/select-field'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { AppButton } from '@/components/app-button'
|
||||
import { api } from '@/lib/api-client'
|
||||
|
||||
const formSchema = z.object({
|
||||
vpsTrackerUrl: z.string().optional().default(''),
|
||||
vpsTrackerIntegrationToken: z.string().optional().default(''),
|
||||
vpsTrackerSyncEnabled: z.boolean().default(false),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>
|
||||
|
||||
export interface AppSettingsView {
|
||||
vpsTrackerUrl: string
|
||||
vpsTrackerIntegrationTokenSet: boolean
|
||||
vpsTrackerSyncEnabled: boolean
|
||||
vpsTrackerLastSyncAt: string | null
|
||||
}
|
||||
|
||||
interface VpsTrackerIntegrationCardProps {
|
||||
settings?: AppSettingsView
|
||||
onSave: (values: FormValues) => void
|
||||
isSaving?: boolean
|
||||
}
|
||||
|
||||
export function VpsTrackerIntegrationCard({
|
||||
settings,
|
||||
onSave,
|
||||
isSaving,
|
||||
}: VpsTrackerIntegrationCardProps) {
|
||||
const form = useForm({
|
||||
resolver: zodResolver(formSchema),
|
||||
values: {
|
||||
vpsTrackerUrl: settings?.vpsTrackerUrl ?? '',
|
||||
vpsTrackerIntegrationToken: '',
|
||||
vpsTrackerSyncEnabled: settings?.vpsTrackerSyncEnabled ?? false,
|
||||
},
|
||||
})
|
||||
|
||||
async function handleTest() {
|
||||
const result = await api.post<{ ok: boolean; error?: string }>(
|
||||
'/api/v1/settings/vps-tracker/test',
|
||||
)
|
||||
if (result.ok) toast.success('Связь с VPS Tracker установлена')
|
||||
else toast.error(result.error ?? 'Ошибка проверки')
|
||||
}
|
||||
|
||||
return (
|
||||
<AppCard>
|
||||
<AppCardHeader>
|
||||
<AppCardTitle>VPS Tracker</AppCardTitle>
|
||||
<AppCardDescription>
|
||||
Исходящая синхронизация доменов и сервисов. URL API VPS Tracker (обычно порт 3001).
|
||||
</AppCardDescription>
|
||||
</AppCardHeader>
|
||||
<AppCardContent>
|
||||
<form className="flex flex-col gap-4" onSubmit={(e) => void form.handleSubmit((values) => onSave(values))(e)}>
|
||||
<FieldGroup>
|
||||
<FormFieldSimple label="URL VPS Tracker" htmlFor="vps-url">
|
||||
<Input
|
||||
id="vps-url"
|
||||
placeholder="http://192.168.100.67:3001"
|
||||
{...form.register('vpsTrackerUrl')}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
<FormFieldSimple label="Integration token" htmlFor="vps-token">
|
||||
<Input
|
||||
id="vps-token"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={
|
||||
settings?.vpsTrackerIntegrationTokenSet
|
||||
? 'Токен установлен — введите новый для замены'
|
||||
: 'Тот же токен, что в VPS Tracker'
|
||||
}
|
||||
{...form.register('vpsTrackerIntegrationToken')}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="vpsTrackerSyncEnabled"
|
||||
render={({ field }) => (
|
||||
<FormFieldSimple label="Синхронизация включена" htmlFor="vps-sync">
|
||||
<SelectField
|
||||
triggerId="vps-sync"
|
||||
triggerClassName="w-32"
|
||||
value={field.value ? 'on' : 'off'}
|
||||
onValueChange={(v) => field.onChange((v ?? 'on') === 'on')}
|
||||
options={[
|
||||
{ value: 'on', label: 'Вкл' },
|
||||
{ value: 'off', label: 'Выкл' },
|
||||
]}
|
||||
/>
|
||||
</FormFieldSimple>
|
||||
)}
|
||||
/>
|
||||
{settings?.vpsTrackerLastSyncAt ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Последний sync:{' '}
|
||||
{new Date(settings.vpsTrackerLastSyncAt).toLocaleString('ru-RU')}
|
||||
</p>
|
||||
) : null}
|
||||
</FieldGroup>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<LoadingButton type="submit" isLoading={isSaving} disabled={!form.formState.isDirty}>
|
||||
Сохранить
|
||||
</LoadingButton>
|
||||
<AppButton type="button" variant="outline" onClick={() => void handleTest()}>
|
||||
Проверить связь
|
||||
</AppButton>
|
||||
</div>
|
||||
</form>
|
||||
</AppCardContent>
|
||||
</AppCard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import * as React from 'react'
|
||||
import type { SelectRootProps } from '@base-ui/react/select'
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@cfdm/ui/components/select'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
|
||||
export interface SelectOption {
|
||||
value: string
|
||||
label: React.ReactNode
|
||||
}
|
||||
|
||||
interface SelectFieldProps extends Omit<SelectRootProps<string>, 'items' | 'value' | 'onValueChange'> {
|
||||
options: SelectOption[]
|
||||
placeholder?: string
|
||||
triggerClassName?: string
|
||||
triggerId?: string
|
||||
size?: 'sm' | 'default'
|
||||
value?: string | null
|
||||
onValueChange?: (value: string | null) => void
|
||||
invalid?: boolean
|
||||
'aria-label'?: string
|
||||
}
|
||||
|
||||
export function SelectField({
|
||||
options,
|
||||
placeholder,
|
||||
triggerClassName,
|
||||
triggerId,
|
||||
size = 'default',
|
||||
value,
|
||||
onValueChange,
|
||||
invalid,
|
||||
'aria-label': ariaLabel,
|
||||
...props
|
||||
}: SelectFieldProps) {
|
||||
const items = React.useMemo(
|
||||
() => options.map((o) => ({ value: o.value, label: o.label })),
|
||||
[options],
|
||||
)
|
||||
|
||||
return (
|
||||
<Select items={items} value={value} onValueChange={onValueChange} {...props}>
|
||||
<SelectTrigger
|
||||
id={triggerId}
|
||||
size={size}
|
||||
aria-label={ariaLabel}
|
||||
aria-invalid={invalid || undefined}
|
||||
className={cn('w-full', triggerClassName)}
|
||||
>
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { AppSwitcherConfig } from '@cfdm/shared'
|
||||
import { appSwitcherQueryOptions } from '@/queries/app-switcher'
|
||||
import { getAppUrl as getAppUrlFromConfig } from '@/lib/app-switcher-config'
|
||||
|
||||
import { DEFAULT_APP_SWITCHER_CONFIG } from '@/lib/app-switcher-config'
|
||||
|
||||
export function useAppSwitcherConfig(): {
|
||||
config: AppSwitcherConfig
|
||||
isLoading: boolean
|
||||
} {
|
||||
const { data, isLoading } = useQuery(appSwitcherQueryOptions())
|
||||
return {
|
||||
config: data ?? DEFAULT_APP_SWITCHER_CONFIG,
|
||||
isLoading,
|
||||
}
|
||||
}
|
||||
|
||||
export function useAppUrl(appId: string): string | undefined {
|
||||
const { config } = useAppSwitcherConfig()
|
||||
return getAppUrlFromConfig(appId, config)
|
||||
}
|
||||
@@ -79,6 +79,13 @@ export function getAppSwitcherConfig(): AppSwitcherConfig {
|
||||
return parseAppSwitcherConfig(import.meta.env.VITE_APP_SWITCHER)
|
||||
}
|
||||
|
||||
export function getAppUrl(
|
||||
appId: string,
|
||||
config: AppSwitcherConfig = getAppSwitcherConfig(),
|
||||
): string | undefined {
|
||||
return config.apps.find((app) => app.id === appId)?.url
|
||||
}
|
||||
|
||||
export function getCurrentApp(
|
||||
config: AppSwitcherConfig = getAppSwitcherConfig(),
|
||||
): AppSwitcherEntry {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import type { AppSwitcherConfig } from '@cfdm/shared'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { DEFAULT_APP_SWITCHER_CONFIG } from '@/lib/app-switcher-config'
|
||||
|
||||
export const appSwitcherQueryKey = ['app-switcher'] as const
|
||||
|
||||
export function appSwitcherQueryOptions() {
|
||||
return queryOptions({
|
||||
queryKey: appSwitcherQueryKey,
|
||||
queryFn: () => api.get<AppSwitcherConfig>('/api/v1/settings/app-switcher'),
|
||||
staleTime: 60_000,
|
||||
placeholderData: DEFAULT_APP_SWITCHER_CONFIG,
|
||||
})
|
||||
}
|
||||
@@ -15,7 +15,10 @@ import { Route as AuthIndexRouteImport } from './routes/_auth/index'
|
||||
import { Route as AuthServicesRouteImport } from './routes/_auth/services'
|
||||
import { Route as AuthGroupsRouteImport } from './routes/_auth/groups'
|
||||
import { Route as AuthCertificatesRouteImport } from './routes/_auth/certificates'
|
||||
import { Route as AuthSettingsRouteRouteImport } from './routes/_auth/settings/route'
|
||||
import { Route as AuthSettingsIndexRouteImport } from './routes/_auth/settings/index'
|
||||
import { Route as AuthDomainsIndexRouteImport } from './routes/_auth/domains/index'
|
||||
import { Route as AuthSettingsIntegrationsRouteImport } from './routes/_auth/settings/integrations'
|
||||
import { Route as AuthGroupsGroupIdRouteImport } from './routes/_auth/groups/$groupId'
|
||||
import { Route as AuthDomainsDomainIdIndexRouteImport } from './routes/_auth/domains/$domainId/index'
|
||||
import { Route as AuthDomainsDomainIdDnsRouteImport } from './routes/_auth/domains/$domainId/dns'
|
||||
@@ -49,11 +52,27 @@ const AuthCertificatesRoute = AuthCertificatesRouteImport.update({
|
||||
path: '/certificates',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthSettingsRouteRoute = AuthSettingsRouteRouteImport.update({
|
||||
id: '/settings',
|
||||
path: '/settings',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthSettingsIndexRoute = AuthSettingsIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => AuthSettingsRouteRoute,
|
||||
} as any)
|
||||
const AuthDomainsIndexRoute = AuthDomainsIndexRouteImport.update({
|
||||
id: '/domains/',
|
||||
path: '/domains/',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthSettingsIntegrationsRoute =
|
||||
AuthSettingsIntegrationsRouteImport.update({
|
||||
id: '/integrations',
|
||||
path: '/integrations',
|
||||
getParentRoute: () => AuthSettingsRouteRoute,
|
||||
} as any)
|
||||
const AuthGroupsGroupIdRoute = AuthGroupsGroupIdRouteImport.update({
|
||||
id: '/$groupId',
|
||||
path: '/$groupId',
|
||||
@@ -74,11 +93,14 @@ const AuthDomainsDomainIdDnsRoute = AuthDomainsDomainIdDnsRouteImport.update({
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof AuthIndexRoute
|
||||
'/login': typeof LoginRoute
|
||||
'/settings': typeof AuthSettingsRouteRouteWithChildren
|
||||
'/certificates': typeof AuthCertificatesRoute
|
||||
'/groups': typeof AuthGroupsRouteWithChildren
|
||||
'/services': typeof AuthServicesRoute
|
||||
'/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
||||
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
||||
'/domains/': typeof AuthDomainsIndexRoute
|
||||
'/settings/': typeof AuthSettingsIndexRoute
|
||||
'/domains/$domainId/dns': typeof AuthDomainsDomainIdDnsRoute
|
||||
'/domains/$domainId/': typeof AuthDomainsDomainIdIndexRoute
|
||||
}
|
||||
@@ -89,7 +111,9 @@ export interface FileRoutesByTo {
|
||||
'/services': typeof AuthServicesRoute
|
||||
'/': typeof AuthIndexRoute
|
||||
'/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
||||
'/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
||||
'/domains': typeof AuthDomainsIndexRoute
|
||||
'/settings': typeof AuthSettingsIndexRoute
|
||||
'/domains/$domainId/dns': typeof AuthDomainsDomainIdDnsRoute
|
||||
'/domains/$domainId': typeof AuthDomainsDomainIdIndexRoute
|
||||
}
|
||||
@@ -97,12 +121,15 @@ export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/_auth': typeof AuthRouteWithChildren
|
||||
'/login': typeof LoginRoute
|
||||
'/_auth/settings': typeof AuthSettingsRouteRouteWithChildren
|
||||
'/_auth/certificates': typeof AuthCertificatesRoute
|
||||
'/_auth/groups': typeof AuthGroupsRouteWithChildren
|
||||
'/_auth/services': typeof AuthServicesRoute
|
||||
'/_auth/': typeof AuthIndexRoute
|
||||
'/_auth/groups/$groupId': typeof AuthGroupsGroupIdRoute
|
||||
'/_auth/settings/integrations': typeof AuthSettingsIntegrationsRoute
|
||||
'/_auth/domains/': typeof AuthDomainsIndexRoute
|
||||
'/_auth/settings/': typeof AuthSettingsIndexRoute
|
||||
'/_auth/domains/$domainId/dns': typeof AuthDomainsDomainIdDnsRoute
|
||||
'/_auth/domains/$domainId/': typeof AuthDomainsDomainIdIndexRoute
|
||||
}
|
||||
@@ -111,11 +138,14 @@ export interface FileRouteTypes {
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/login'
|
||||
| '/settings'
|
||||
| '/certificates'
|
||||
| '/groups'
|
||||
| '/services'
|
||||
| '/groups/$groupId'
|
||||
| '/settings/integrations'
|
||||
| '/domains/'
|
||||
| '/settings/'
|
||||
| '/domains/$domainId/dns'
|
||||
| '/domains/$domainId/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
@@ -126,19 +156,24 @@ export interface FileRouteTypes {
|
||||
| '/services'
|
||||
| '/'
|
||||
| '/groups/$groupId'
|
||||
| '/settings/integrations'
|
||||
| '/domains'
|
||||
| '/settings'
|
||||
| '/domains/$domainId/dns'
|
||||
| '/domains/$domainId'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/_auth'
|
||||
| '/login'
|
||||
| '/_auth/settings'
|
||||
| '/_auth/certificates'
|
||||
| '/_auth/groups'
|
||||
| '/_auth/services'
|
||||
| '/_auth/'
|
||||
| '/_auth/groups/$groupId'
|
||||
| '/_auth/settings/integrations'
|
||||
| '/_auth/domains/'
|
||||
| '/_auth/settings/'
|
||||
| '/_auth/domains/$domainId/dns'
|
||||
| '/_auth/domains/$domainId/'
|
||||
fileRoutesById: FileRoutesById
|
||||
@@ -192,6 +227,20 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthCertificatesRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/settings': {
|
||||
id: '/_auth/settings'
|
||||
path: '/settings'
|
||||
fullPath: '/settings'
|
||||
preLoaderRoute: typeof AuthSettingsRouteRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/settings/': {
|
||||
id: '/_auth/settings/'
|
||||
path: '/'
|
||||
fullPath: '/settings/'
|
||||
preLoaderRoute: typeof AuthSettingsIndexRouteImport
|
||||
parentRoute: typeof AuthSettingsRouteRoute
|
||||
}
|
||||
'/_auth/domains/': {
|
||||
id: '/_auth/domains/'
|
||||
path: '/domains'
|
||||
@@ -199,6 +248,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthDomainsIndexRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/settings/integrations': {
|
||||
id: '/_auth/settings/integrations'
|
||||
path: '/integrations'
|
||||
fullPath: '/settings/integrations'
|
||||
preLoaderRoute: typeof AuthSettingsIntegrationsRouteImport
|
||||
parentRoute: typeof AuthSettingsRouteRoute
|
||||
}
|
||||
'/_auth/groups/$groupId': {
|
||||
id: '/_auth/groups/$groupId'
|
||||
path: '/$groupId'
|
||||
@@ -223,6 +279,19 @@ declare module '@tanstack/react-router' {
|
||||
}
|
||||
}
|
||||
|
||||
interface AuthSettingsRouteRouteChildren {
|
||||
AuthSettingsIntegrationsRoute: typeof AuthSettingsIntegrationsRoute
|
||||
AuthSettingsIndexRoute: typeof AuthSettingsIndexRoute
|
||||
}
|
||||
|
||||
const AuthSettingsRouteRouteChildren: AuthSettingsRouteRouteChildren = {
|
||||
AuthSettingsIntegrationsRoute: AuthSettingsIntegrationsRoute,
|
||||
AuthSettingsIndexRoute: AuthSettingsIndexRoute,
|
||||
}
|
||||
|
||||
const AuthSettingsRouteRouteWithChildren =
|
||||
AuthSettingsRouteRoute._addFileChildren(AuthSettingsRouteRouteChildren)
|
||||
|
||||
interface AuthGroupsRouteChildren {
|
||||
AuthGroupsGroupIdRoute: typeof AuthGroupsGroupIdRoute
|
||||
}
|
||||
@@ -236,6 +305,7 @@ const AuthGroupsRouteWithChildren = AuthGroupsRoute._addFileChildren(
|
||||
)
|
||||
|
||||
interface AuthRouteChildren {
|
||||
AuthSettingsRouteRoute: typeof AuthSettingsRouteRouteWithChildren
|
||||
AuthCertificatesRoute: typeof AuthCertificatesRoute
|
||||
AuthGroupsRoute: typeof AuthGroupsRouteWithChildren
|
||||
AuthServicesRoute: typeof AuthServicesRoute
|
||||
@@ -246,6 +316,7 @@ interface AuthRouteChildren {
|
||||
}
|
||||
|
||||
const AuthRouteChildren: AuthRouteChildren = {
|
||||
AuthSettingsRouteRoute: AuthSettingsRouteRouteWithChildren,
|
||||
AuthCertificatesRoute: AuthCertificatesRoute,
|
||||
AuthGroupsRoute: AuthGroupsRouteWithChildren,
|
||||
AuthServicesRoute: AuthServicesRoute,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/_auth/settings/')({
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: '/settings/integrations' })
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
import type { AppSettingsPatch } from '@cfdm/shared'
|
||||
|
||||
import { api } from '@/lib/api-client'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { AppSwitcherEditor } from '@/components/integrations/app-switcher-editor'
|
||||
import {
|
||||
VpsTrackerIntegrationCard,
|
||||
type AppSettingsView,
|
||||
} from '@/components/integrations/vps-tracker-integration-card'
|
||||
import { DEFAULT_APP_SWITCHER_CONFIG } from '@/lib/app-switcher-config'
|
||||
import { appSwitcherQueryKey } from '@/queries/app-switcher'
|
||||
|
||||
type SettingsResponse = AppSettingsView & {
|
||||
id: string
|
||||
appSwitcher: typeof DEFAULT_APP_SWITCHER_CONFIG
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/_auth/settings/integrations')({
|
||||
component: SettingsIntegrationsPage,
|
||||
})
|
||||
|
||||
function SettingsIntegrationsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: ['app-settings'],
|
||||
queryFn: () => api.get<SettingsResponse>('/api/v1/settings'),
|
||||
})
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: (patch: AppSettingsPatch) =>
|
||||
api.patch<SettingsResponse>('/api/v1/settings', patch),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['app-settings'] })
|
||||
await queryClient.invalidateQueries({ queryKey: appSwitcherQueryKey })
|
||||
toast.success('Настройки сохранены')
|
||||
},
|
||||
onError: () => toast.error('Не удалось сохранить'),
|
||||
})
|
||||
|
||||
return (
|
||||
<QueryState
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={() => refetch()}
|
||||
>
|
||||
{data ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<AppSwitcherEditor
|
||||
defaultValues={data.appSwitcher ?? DEFAULT_APP_SWITCHER_CONFIG}
|
||||
isSaving={saveMut.isPending}
|
||||
onSave={(appSwitcher) => saveMut.mutate({ appSwitcher })}
|
||||
/>
|
||||
<VpsTrackerIntegrationCard
|
||||
settings={data}
|
||||
isSaving={saveMut.isPending}
|
||||
onSave={(values) => {
|
||||
const patch: AppSettingsPatch = {
|
||||
vpsTrackerUrl: values.vpsTrackerUrl,
|
||||
vpsTrackerSyncEnabled: values.vpsTrackerSyncEnabled,
|
||||
}
|
||||
const token = values.vpsTrackerIntegrationToken?.trim()
|
||||
if (token) patch.vpsTrackerIntegrationToken = token
|
||||
saveMut.mutate(patch)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</QueryState>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { createFileRoute, Link, Outlet, useRouterState } from '@tanstack/react-router'
|
||||
import { cn } from '@cfdm/ui/lib/utils'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
|
||||
export const Route = createFileRoute('/_auth/settings')({
|
||||
component: SettingsLayout,
|
||||
})
|
||||
|
||||
const TABS = [{ to: '/settings/integrations', label: 'Интеграции' }] as const
|
||||
|
||||
function SettingsLayout() {
|
||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader title="Настройки" description="Интеграции с другими приложениями" />
|
||||
<nav className="flex gap-1 border-b pb-0">
|
||||
{TABS.map((tab) => {
|
||||
const active = pathname.startsWith(tab.to)
|
||||
return (
|
||||
<Link
|
||||
key={tab.to}
|
||||
to={tab.to}
|
||||
className={cn(
|
||||
'rounded-t-md px-4 py-2 text-sm font-medium transition-colors',
|
||||
active
|
||||
? 'border border-b-0 bg-background text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
<Outlet />
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user