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

Исходящий sync bindings после updateConfig, настройки в app_settings, страница Интеграции, приём событий vps_down для DNS failover.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-06-30 16:45:17 +07:00
co-authored by Cursor
parent 859918fae2
commit 96783386e0
32 changed files with 2116 additions and 243 deletions
+1 -2
View File
@@ -12,8 +12,7 @@ ADMIN_USERNAME=admin
ADMIN_PASSWORD_HASH=
# Frontend (Vite)
# Список приложений для sidebar switcher (JSON, опционально)
# VITE_APP_SWITCHER={"menuLabel":"Приложения","apps":[{"id":"vps-tracker","name":"VPS Tracker","subtitle":"Учёт VPS","url":"http://192.168.100.67:3001","icon":"server"},{"id":"cfdm","name":"CF Domain Manager","subtitle":"Домены","url":"http://192.168.100.67:6363","icon":"cloud"},{"id":"grafana","name":"Grafana","url":"https://grafana.example.com","icon":"chart"}]}
# Публичные URL приложений и integration token настраиваются в UI: Настройки → Интеграции
# Server
SERVER_PORT=8080
+365 -161
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -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" },
);
+5
View File
@@ -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 };
});
}
+22
View File
@@ -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;
}
+126
View File
@@ -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 : "Ошибка сети",
};
}
}
+2
View File
@@ -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({
+3 -3
View File
@@ -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>
)
}
+67
View File
@@ -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>
)
}
+22
View File
@@ -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)
}
+7
View File
@@ -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 {
+15
View File
@@ -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,
})
}
+71
View File
@@ -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>
)
}
+340 -2
View File
@@ -1,7 +1,7 @@
import * as drizzle_orm_sqlite_core from 'drizzle-orm/sqlite-core';
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { LbMode, HealthCheckType, ServiceBinding, Domain, Group, Service, ServiceGroup, Subdomain, HealthCheckScope, DnsRecord, ServiceBindingView, Certificate, GroupWithStats, IpHealthStatus, SyncJob, DomainListItem, HealthCheckTarget } from '@cfdm/shared';
import { AppSwitcherConfig, LbMode, HealthCheckType, ServiceBinding, Domain, Group, Service, ServiceGroup, Subdomain, HealthCheckScope, DnsRecord, ServiceBindingView, Certificate, GroupWithStats, IpHealthStatus, SyncJob, DomainListItem, HealthCheckTarget } from '@cfdm/shared';
declare const groups: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{
name: "groups";
@@ -2226,6 +2226,163 @@ declare const ipHealthStatus: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{
};
dialect: "sqlite";
}>;
declare const appSettings: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{
name: "app_settings";
schema: undefined;
columns: {
id: drizzle_orm_sqlite_core.SQLiteColumn<{
name: "id";
tableName: "app_settings";
dataType: "string";
columnType: "SQLiteText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: true;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
identity: undefined;
generated: undefined;
}, {}, {
length: number | undefined;
}>;
app_switcher_json: drizzle_orm_sqlite_core.SQLiteColumn<{
name: "app_switcher_json";
tableName: "app_settings";
dataType: "string";
columnType: "SQLiteText";
data: string;
driverParam: string;
notNull: false;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
identity: undefined;
generated: undefined;
}, {}, {
length: number | undefined;
}>;
vps_tracker_url: drizzle_orm_sqlite_core.SQLiteColumn<{
name: "vps_tracker_url";
tableName: "app_settings";
dataType: "string";
columnType: "SQLiteText";
data: string;
driverParam: string;
notNull: false;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
identity: undefined;
generated: undefined;
}, {}, {
length: number | undefined;
}>;
vps_tracker_integration_token: drizzle_orm_sqlite_core.SQLiteColumn<{
name: "vps_tracker_integration_token";
tableName: "app_settings";
dataType: "string";
columnType: "SQLiteText";
data: string;
driverParam: string;
notNull: false;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
identity: undefined;
generated: undefined;
}, {}, {
length: number | undefined;
}>;
vps_tracker_sync_enabled: drizzle_orm_sqlite_core.SQLiteColumn<{
name: "vps_tracker_sync_enabled";
tableName: "app_settings";
dataType: "boolean";
columnType: "SQLiteBoolean";
data: boolean;
driverParam: number;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
identity: undefined;
generated: undefined;
}, {}, {}>;
vps_tracker_last_sync_at: drizzle_orm_sqlite_core.SQLiteColumn<{
name: "vps_tracker_last_sync_at";
tableName: "app_settings";
dataType: "string";
columnType: "SQLiteText";
data: string;
driverParam: string;
notNull: false;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
identity: undefined;
generated: undefined;
}, {}, {
length: number | undefined;
}>;
created_at: drizzle_orm_sqlite_core.SQLiteColumn<{
name: "created_at";
tableName: "app_settings";
dataType: "string";
columnType: "SQLiteText";
data: string;
driverParam: string;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
identity: undefined;
generated: undefined;
}, {}, {
length: number | undefined;
}>;
updated_at: drizzle_orm_sqlite_core.SQLiteColumn<{
name: "updated_at";
tableName: "app_settings";
dataType: "string";
columnType: "SQLiteText";
data: string;
driverParam: string;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
identity: undefined;
generated: undefined;
}, {}, {
length: number | undefined;
}>;
};
dialect: "sqlite";
}>;
declare const schema: {
groups: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{
name: "groups";
@@ -4450,6 +4607,163 @@ declare const schema: {
};
dialect: "sqlite";
}>;
appSettings: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{
name: "app_settings";
schema: undefined;
columns: {
id: drizzle_orm_sqlite_core.SQLiteColumn<{
name: "id";
tableName: "app_settings";
dataType: "string";
columnType: "SQLiteText";
data: string;
driverParam: string;
notNull: true;
hasDefault: false;
isPrimaryKey: true;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
identity: undefined;
generated: undefined;
}, {}, {
length: number | undefined;
}>;
app_switcher_json: drizzle_orm_sqlite_core.SQLiteColumn<{
name: "app_switcher_json";
tableName: "app_settings";
dataType: "string";
columnType: "SQLiteText";
data: string;
driverParam: string;
notNull: false;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
identity: undefined;
generated: undefined;
}, {}, {
length: number | undefined;
}>;
vps_tracker_url: drizzle_orm_sqlite_core.SQLiteColumn<{
name: "vps_tracker_url";
tableName: "app_settings";
dataType: "string";
columnType: "SQLiteText";
data: string;
driverParam: string;
notNull: false;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
identity: undefined;
generated: undefined;
}, {}, {
length: number | undefined;
}>;
vps_tracker_integration_token: drizzle_orm_sqlite_core.SQLiteColumn<{
name: "vps_tracker_integration_token";
tableName: "app_settings";
dataType: "string";
columnType: "SQLiteText";
data: string;
driverParam: string;
notNull: false;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
identity: undefined;
generated: undefined;
}, {}, {
length: number | undefined;
}>;
vps_tracker_sync_enabled: drizzle_orm_sqlite_core.SQLiteColumn<{
name: "vps_tracker_sync_enabled";
tableName: "app_settings";
dataType: "boolean";
columnType: "SQLiteBoolean";
data: boolean;
driverParam: number;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: undefined;
baseColumn: never;
identity: undefined;
generated: undefined;
}, {}, {}>;
vps_tracker_last_sync_at: drizzle_orm_sqlite_core.SQLiteColumn<{
name: "vps_tracker_last_sync_at";
tableName: "app_settings";
dataType: "string";
columnType: "SQLiteText";
data: string;
driverParam: string;
notNull: false;
hasDefault: false;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
identity: undefined;
generated: undefined;
}, {}, {
length: number | undefined;
}>;
created_at: drizzle_orm_sqlite_core.SQLiteColumn<{
name: "created_at";
tableName: "app_settings";
dataType: "string";
columnType: "SQLiteText";
data: string;
driverParam: string;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
identity: undefined;
generated: undefined;
}, {}, {
length: number | undefined;
}>;
updated_at: drizzle_orm_sqlite_core.SQLiteColumn<{
name: "updated_at";
tableName: "app_settings";
dataType: "string";
columnType: "SQLiteText";
data: string;
driverParam: string;
notNull: true;
hasDefault: true;
isPrimaryKey: false;
isAutoincrement: false;
hasRuntimeDefault: false;
enumValues: [string, ...string[]];
baseColumn: never;
identity: undefined;
generated: undefined;
}, {}, {
length: number | undefined;
}>;
};
dialect: "sqlite";
}>;
};
type Sqlite = Database.Database;
@@ -4473,6 +4787,30 @@ declare class ConflictError extends Error {
constructor(message: string);
}
type AppSettingsDto = {
id: string;
appSwitcher: AppSwitcherConfig;
vpsTrackerUrl: string;
vpsTrackerIntegrationTokenSet: boolean;
vpsTrackerSyncEnabled: boolean;
vpsTrackerLastSyncAt: string | null;
};
type AppSettingsPatch = {
appSwitcher?: AppSwitcherConfig;
vpsTrackerUrl?: string;
vpsTrackerIntegrationToken?: string;
vpsTrackerSyncEnabled?: boolean;
};
declare function getAppSettings(db: Db): AppSettingsDto;
declare function getAppSettingsSecrets(db: Db): {
vpsTrackerUrl: string;
vpsTrackerIntegrationToken: string;
vpsTrackerSyncEnabled: boolean;
};
declare function updateAppSettings(db: Db, patch: AppSettingsPatch): AppSettingsDto;
declare function touchVpsTrackerSync(db: Db): void;
declare function getAppSwitcher(db: Db): AppSwitcherConfig;
interface DnsListFilter {
record_type?: string;
name?: string;
@@ -4705,4 +5043,4 @@ declare namespace repos {
export { type repos_BindingIpMeta as BindingIpMeta, type repos_BindingLbPatch as BindingLbPatch, type repos_DnsListFilter as DnsListFilter, type repos_ServiceGroupLbPatch as ServiceGroupLbPatch, type repos_UpdateSubdomainPatch as UpdateSubdomainPatch, repos_bindingsToRemove as bindingsToRemove, repos_countCertificatesByStatus as countCertificatesByStatus, repos_createDomain as createDomain, repos_createGroup as createGroup, repos_createService as createService, repos_createServiceGroup as createServiceGroup, repos_createSubdomain as createSubdomain, repos_createSyncJob as createSyncJob, repos_deleteBinding as deleteBinding, repos_deleteBindingsExcept as deleteBindingsExcept, repos_deleteCertificatesNotIn as deleteCertificatesNotIn, repos_deleteDnsRecord as deleteDnsRecord, repos_deleteDomain as deleteDomain, repos_deleteGroup as deleteGroup, repos_deleteIpHealthStatusForIp as deleteIpHealthStatusForIp, repos_deleteIpHealthStatusForRef as deleteIpHealthStatusForRef, repos_deleteService as deleteService, repos_deleteServiceGroup as deleteServiceGroup, repos_deleteSubdomain as deleteSubdomain, repos_findBinding as findBinding, repos_findDnsByCfId as findDnsByCfId, repos_findDomainByZoneName as findDomainByZoneName, repos_findSubdomainByDomainAndName as findSubdomainByDomainAndName, repos_finishSyncJob as finishSyncJob, repos_getBinding as getBinding, repos_getBindingView as getBindingView, repos_getCertificate as getCertificate, repos_getDnsRecord as getDnsRecord, repos_getDomain as getDomain, repos_getGroup as getGroup, repos_getGroupWithStats as getGroupWithStats, repos_getIpHealthStatusRow as getIpHealthStatusRow, repos_getService as getService, repos_getServiceGroup as getServiceGroup, repos_getSubdomain as getSubdomain, repos_getSyncJob as getSyncJob, repos_insertBinding as insertBinding, repos_insertDnsRecord as insertDnsRecord, repos_linkBindingRecord as linkBindingRecord, repos_linkGroupDnsRecord as linkGroupDnsRecord, repos_listAllBindings as listAllBindings, repos_listAllDomains as listAllDomains, repos_listAllSubdomains as listAllSubdomains, repos_listBindingIps as listBindingIps, repos_listBindingIpsWithMeta as listBindingIpsWithMeta, repos_listBindingsByDomain as listBindingsByDomain, repos_listBindingsByService as listBindingsByService, repos_listCertificates as listCertificates, repos_listDnsByDomain as listDnsByDomain, repos_listDnsRecords as listDnsRecords, repos_listDomains as listDomains, repos_listDomainsEnriched as listDomainsEnriched, repos_listGroupDnsRecords as listGroupDnsRecords, repos_listGroups as listGroups, repos_listHealthCheckTargets as listHealthCheckTargets, repos_listIpHealthStatus as listIpHealthStatus, repos_listRecordsForBinding as listRecordsForBinding, repos_listServiceGroups as listServiceGroups, repos_listServiceIps as listServiceIps, repos_listServices as listServices, repos_listServicesByGroup as listServicesByGroup, repos_listSubdomainsByDomain as listSubdomainsByDomain, repos_listUngroupedServices as listUngroupedServices, repos_markDnsPendingDelete as markDnsPendingDelete, repos_reorderServices as reorderServices, repos_replaceBindingIps as replaceBindingIps, repos_replaceBindingIpsWithMeta as replaceBindingIpsWithMeta, repos_replaceServiceIps as replaceServiceIps, repos_setBindingCnameTarget as setBindingCnameTarget, repos_setBindingDnsRecordId as setBindingDnsRecordId, repos_setDnsSyncStatus as setDnsSyncStatus, repos_setDomainLastSynced as setDomainLastSynced, repos_setServiceEnabled as setServiceEnabled, repos_setServiceGroup as setServiceGroup, repos_setServiceGroupEnabled as setServiceGroupEnabled, repos_setServiceLb as setServiceLb, repos_unlinkBindingRecord as unlinkBindingRecord, repos_unlinkGroupDnsRecord as unlinkGroupDnsRecord, repos_updateBindingFields as updateBindingFields, repos_updateBindingLbConfig as updateBindingLbConfig, repos_updateDnsFields as updateDnsFields, repos_updateDomain as updateDomain, repos_updateGroup as updateGroup, repos_updateService as updateService, repos_updateServiceGroup as updateServiceGroup, repos_updateSubdomain as updateSubdomain, repos_upsertCertificateCheck as upsertCertificateCheck, repos_upsertIpHealthStatus as upsertIpHealthStatus, repos_upsertSubdomain as upsertSubdomain };
}
export { ConflictError, type Db, type DnsListFilter, NotFoundError, type Sqlite, type UpdateSubdomainPatch, certificates, createDb, createMemoryDb, dnsRecords, domains, groups, healthCheck, ipHealthStatus, repos, resolveDatabasePath, runMigrations, schema, serviceBindingIps, serviceBindingRecords, serviceBindings, serviceGroupDnsRecords, serviceGroups, serviceIps, services, subdomains, syncJobs };
export { type AppSettingsDto, type AppSettingsPatch, ConflictError, type Db, type DnsListFilter, NotFoundError, type Sqlite, type UpdateSubdomainPatch, appSettings, certificates, createDb, createMemoryDb, dnsRecords, domains, getAppSettings, getAppSettingsSecrets, getAppSwitcher, groups, healthCheck, ipHealthStatus, repos, resolveDatabasePath, runMigrations, schema, serviceBindingIps, serviceBindingRecords, serviceBindings, serviceGroupDnsRecords, serviceGroups, serviceIps, services, subdomains, syncJobs, touchVpsTrackerSync, updateAppSettings };
+180 -73
View File
@@ -184,6 +184,18 @@ var ipHealthStatus = sqliteTable(
},
(t) => [primaryKey({ columns: [t.scope, t.ref_id, t.ip] })]
);
var appSettings = sqliteTable("app_settings", {
id: text("id").primaryKey(),
app_switcher_json: text("app_switcher_json"),
vps_tracker_url: text("vps_tracker_url"),
vps_tracker_integration_token: text("vps_tracker_integration_token"),
vps_tracker_sync_enabled: integer("vps_tracker_sync_enabled", {
mode: "boolean"
}).notNull().default(false),
vps_tracker_last_sync_at: text("vps_tracker_last_sync_at"),
created_at: text("created_at").notNull().default(sql`datetime('now')`),
updated_at: text("updated_at").notNull().default(sql`datetime('now')`)
});
var schema = {
groups,
services,
@@ -198,7 +210,8 @@ var schema = {
serviceGroupDnsRecords,
certificates,
syncJobs,
ipHealthStatus
ipHealthStatus,
appSettings
};
// src/client.ts
@@ -263,6 +276,94 @@ var ConflictError = class extends Error {
}
};
// src/settings-repo.ts
import { eq } from "drizzle-orm";
import { appSwitcherConfigSchema } from "@cfdm/shared";
var SETTINGS_ID = "settings-main";
var DEFAULT_APP_SWITCHER = {
menuLabel: "\u041F\u0440\u0438\u043B\u043E\u0436\u0435\u043D\u0438\u044F",
apps: [
{
id: "vps-tracker",
name: "VPS Tracker",
subtitle: "\u0423\u0447\u0451\u0442 \u0432\u0438\u0440\u0442\u0443\u0430\u043B\u044C\u043D\u044B\u0445 \u0441\u0435\u0440\u0432\u0435\u0440\u043E\u0432",
url: "http://192.168.100.67:3001",
icon: "server",
shortcut: "\u23181"
},
{
id: "cfdm",
name: "CF Domain Manager",
subtitle: "\u0423\u043F\u0440\u0430\u0432\u043B\u0435\u043D\u0438\u0435 \u0434\u043E\u043C\u0435\u043D\u0430\u043C\u0438",
url: "http://192.168.100.67:6363",
icon: "cloud",
shortcut: "\u23182"
}
]
};
function parseAppSwitcher(raw) {
if (!raw?.trim()) return DEFAULT_APP_SWITCHER;
try {
return appSwitcherConfigSchema.parse(JSON.parse(raw));
} catch {
return DEFAULT_APP_SWITCHER;
}
}
function toDto(row) {
return {
id: row.id,
appSwitcher: parseAppSwitcher(row.app_switcher_json),
vpsTrackerUrl: row.vps_tracker_url?.trim() ?? "",
vpsTrackerIntegrationTokenSet: Boolean(
row.vps_tracker_integration_token?.trim()
),
vpsTrackerSyncEnabled: Boolean(row.vps_tracker_sync_enabled),
vpsTrackerLastSyncAt: row.vps_tracker_last_sync_at
};
}
function getAppSettings(db) {
const row = db.select().from(appSettings).where(eq(appSettings.id, SETTINGS_ID)).get();
if (!row) {
db.insert(appSettings).values({ id: SETTINGS_ID }).run();
return toDto(
db.select().from(appSettings).where(eq(appSettings.id, SETTINGS_ID)).get()
);
}
return toDto(row);
}
function getAppSettingsSecrets(db) {
const row = db.select().from(appSettings).where(eq(appSettings.id, SETTINGS_ID)).get();
return {
vpsTrackerUrl: row?.vps_tracker_url?.trim() ?? "",
vpsTrackerIntegrationToken: row?.vps_tracker_integration_token?.trim() ?? "",
vpsTrackerSyncEnabled: Boolean(row?.vps_tracker_sync_enabled)
};
}
function updateAppSettings(db, patch) {
const existing = db.select().from(appSettings).where(eq(appSettings.id, SETTINGS_ID)).get();
if (!existing) {
db.insert(appSettings).values({ id: SETTINGS_ID }).run();
}
const current = db.select().from(appSettings).where(eq(appSettings.id, SETTINGS_ID)).get();
db.update(appSettings).set({
app_switcher_json: patch.appSwitcher !== void 0 ? JSON.stringify(patch.appSwitcher) : current.app_switcher_json,
vps_tracker_url: patch.vpsTrackerUrl !== void 0 ? patch.vpsTrackerUrl : current.vps_tracker_url,
vps_tracker_integration_token: patch.vpsTrackerIntegrationToken !== void 0 && patch.vpsTrackerIntegrationToken.trim() !== "" ? patch.vpsTrackerIntegrationToken : current.vps_tracker_integration_token,
vps_tracker_sync_enabled: patch.vpsTrackerSyncEnabled !== void 0 ? patch.vpsTrackerSyncEnabled : current.vps_tracker_sync_enabled,
updated_at: (/* @__PURE__ */ new Date()).toISOString()
}).where(eq(appSettings.id, SETTINGS_ID)).run();
return getAppSettings(db);
}
function touchVpsTrackerSync(db) {
db.update(appSettings).set({
vps_tracker_last_sync_at: (/* @__PURE__ */ new Date()).toISOString(),
updated_at: (/* @__PURE__ */ new Date()).toISOString()
}).where(eq(appSettings.id, SETTINGS_ID)).run();
}
function getAppSwitcher(db) {
return getAppSettings(db).appSwitcher;
}
// src/repos.ts
var repos_exports = {};
__export(repos_exports, {
@@ -357,12 +458,12 @@ __export(repos_exports, {
upsertSubdomain: () => upsertSubdomain
});
import { dnsRecordNamesMatch } from "@cfdm/shared";
import { and, asc, count, eq, isNull, like, notInArray, or, sql as sql2 } from "drizzle-orm";
import { and, asc, count, eq as eq2, isNull, like, notInArray, or, sql as sql2 } from "drizzle-orm";
function listGroups(db) {
return db.select().from(groups).orderBy(asc(groups.name)).all();
}
function getGroup(db, id) {
const row = db.select().from(groups).where(eq(groups.id, id)).get();
const row = db.select().from(groups).where(eq2(groups.id, id)).get();
if (!row) throw new NotFoundError(`group ${id}`);
return row;
}
@@ -380,17 +481,17 @@ function createGroup(db, name, slug) {
return getGroup(db, id);
}
function updateGroup(db, id, name, slug) {
const result = db.update(groups).set({ name, slug, updated_at: sql2`datetime('now')` }).where(eq(groups.id, id)).run();
const result = db.update(groups).set({ name, slug, updated_at: sql2`datetime('now')` }).where(eq2(groups.id, id)).run();
if (result.changes === 0) throw new NotFoundError(`group ${id}`);
return getGroup(db, id);
}
function deleteGroup(db, id) {
const result = db.delete(groups).where(eq(groups.id, id)).run();
const result = db.delete(groups).where(eq2(groups.id, id)).run();
if (result.changes === 0) throw new NotFoundError(`group ${id}`);
}
function listDomains(db, groupId) {
if (groupId != null) {
return db.select().from(domains).where(eq(domains.group_id, groupId)).orderBy(asc(domains.zone_name)).all();
return db.select().from(domains).where(eq2(domains.group_id, groupId)).orderBy(asc(domains.zone_name)).all();
}
return db.select().from(domains).orderBy(asc(domains.zone_name)).all();
}
@@ -412,7 +513,7 @@ function findDomainByZoneName(db, zoneName) {
return rows[0] ?? null;
}
function getDomain(db, id) {
const row = db.select().from(domains).where(eq(domains.id, id)).get();
const row = db.select().from(domains).where(eq2(domains.id, id)).get();
if (!row) throw new NotFoundError(`domain ${id}`);
return row;
}
@@ -433,33 +534,33 @@ function updateDomain(db, id, groupId, status, certMonitoring) {
if (certMonitoring !== void 0) {
updates.cert_monitoring = certMonitoring;
}
const result = db.update(domains).set(updates).where(eq(domains.id, id)).run();
const result = db.update(domains).set(updates).where(eq2(domains.id, id)).run();
if (result.changes === 0) throw new NotFoundError(`domain ${id}`);
return getDomain(db, id);
}
function deleteDomain(db, id) {
const result = db.delete(domains).where(eq(domains.id, id)).run();
const result = db.delete(domains).where(eq2(domains.id, id)).run();
if (result.changes === 0) throw new NotFoundError(`domain ${id}`);
}
function setDomainLastSynced(db, id) {
db.update(domains).set({
last_synced_at: sql2`datetime('now')`,
updated_at: sql2`datetime('now')`
}).where(eq(domains.id, id)).run();
}).where(eq2(domains.id, id)).run();
}
function listAllDomains(db) {
return listDomains(db);
}
function listSubdomainsByDomain(db, domainId) {
return db.select().from(subdomains).where(eq(subdomains.domain_id, domainId)).orderBy(asc(subdomains.name)).all();
return db.select().from(subdomains).where(eq2(subdomains.domain_id, domainId)).orderBy(asc(subdomains.name)).all();
}
function getSubdomain(db, id) {
const row = db.select().from(subdomains).where(eq(subdomains.id, id)).get();
const row = db.select().from(subdomains).where(eq2(subdomains.id, id)).get();
if (!row) throw new NotFoundError(`subdomain ${id}`);
return row;
}
function findSubdomainByDomainAndName(db, domainId, name) {
const row = db.select().from(subdomains).where(and(eq(subdomains.domain_id, domainId), eq(subdomains.name, name))).get();
const row = db.select().from(subdomains).where(and(eq2(subdomains.domain_id, domainId), eq2(subdomains.name, name))).get();
return row ?? null;
}
function upsertSubdomain(db, domainId, name, fqdn) {
@@ -483,12 +584,12 @@ function updateSubdomain(db, id, patch) {
if (patch.cert_monitoring !== void 0) {
updates.cert_monitoring = patch.cert_monitoring;
}
const result = db.update(subdomains).set(updates).where(eq(subdomains.id, id)).run();
const result = db.update(subdomains).set(updates).where(eq2(subdomains.id, id)).run();
if (result.changes === 0) throw new NotFoundError(`subdomain ${id}`);
return getSubdomain(db, id);
}
function deleteSubdomain(db, id) {
const result = db.delete(subdomains).where(eq(subdomains.id, id)).run();
const result = db.delete(subdomains).where(eq2(subdomains.id, id)).run();
if (result.changes === 0) throw new NotFoundError(`subdomain ${id}`);
}
function listAllSubdomains(db) {
@@ -498,9 +599,9 @@ function mapDnsRecord(row) {
return row;
}
function listDnsRecords(db, domainId, filter = {}) {
const conditions = [eq(dnsRecords.domain_id, domainId)];
const conditions = [eq2(dnsRecords.domain_id, domainId)];
if (filter.record_type) {
conditions.push(eq(dnsRecords.record_type, filter.record_type.toUpperCase()));
conditions.push(eq2(dnsRecords.record_type, filter.record_type.toUpperCase()));
}
if (filter.name) {
conditions.push(like(dnsRecords.name, `%${filter.name}%`));
@@ -509,10 +610,10 @@ function listDnsRecords(db, domainId, filter = {}) {
conditions.push(like(dnsRecords.content, `%${filter.content}%`));
}
if (filter.proxied != null) {
conditions.push(eq(dnsRecords.proxied, filter.proxied));
conditions.push(eq2(dnsRecords.proxied, filter.proxied));
}
if (filter.sync_status) {
conditions.push(eq(dnsRecords.sync_status, filter.sync_status));
conditions.push(eq2(dnsRecords.sync_status, filter.sync_status));
}
if (filter.q) {
const pat = `%${filter.q}%`;
@@ -531,7 +632,7 @@ function listDnsRecords(db, domainId, filter = {}) {
return db.select().from(dnsRecords).where(and(...conditions)).orderBy(asc(sortCol)).limit(limit).offset(offset).all().map(mapDnsRecord);
}
function getDnsRecord(db, domainId, id) {
const row = db.select().from(dnsRecords).where(and(eq(dnsRecords.id, id), eq(dnsRecords.domain_id, domainId))).get();
const row = db.select().from(dnsRecords).where(and(eq2(dnsRecords.id, id), eq2(dnsRecords.domain_id, domainId))).get();
if (!row) throw new NotFoundError(`dns record ${id}`);
return mapDnsRecord(row);
}
@@ -562,7 +663,7 @@ function updateDnsFields(db, id, recordType, name, content, ttl, proxied, priori
sync_status: syncStatus,
last_error: lastError,
updated_at: sql2`datetime('now')`
}).where(eq(dnsRecords.id, id)).run();
}).where(eq2(dnsRecords.id, id)).run();
}
function setDnsSyncStatus(db, id, syncStatus, cfRecordId, lastError) {
db.update(dnsRecords).set({
@@ -570,19 +671,19 @@ function setDnsSyncStatus(db, id, syncStatus, cfRecordId, lastError) {
cf_record_id: cfRecordId,
last_error: lastError,
updated_at: sql2`datetime('now')`
}).where(eq(dnsRecords.id, id)).run();
}).where(eq2(dnsRecords.id, id)).run();
}
function deleteDnsRecord(db, id) {
db.delete(dnsRecords).where(eq(dnsRecords.id, id)).run();
db.delete(dnsRecords).where(eq2(dnsRecords.id, id)).run();
}
function listDnsByDomain(db, domainId) {
return db.select().from(dnsRecords).where(eq(dnsRecords.domain_id, domainId)).all().map(mapDnsRecord);
return db.select().from(dnsRecords).where(eq2(dnsRecords.domain_id, domainId)).all().map(mapDnsRecord);
}
function findDnsByCfId(db, domainId, cfRecordId) {
const row = db.select().from(dnsRecords).where(
and(
eq(dnsRecords.domain_id, domainId),
eq(dnsRecords.cf_record_id, cfRecordId)
eq2(dnsRecords.domain_id, domainId),
eq2(dnsRecords.cf_record_id, cfRecordId)
)
).get();
return row ? mapDnsRecord(row) : null;
@@ -591,7 +692,7 @@ function markDnsPendingDelete(db, id) {
setDnsSyncStatus(db, id, "pending_delete", null, null);
}
function maxSortOrderInGroup(db, groupId) {
const condition = groupId === null ? isNull(services.service_group_id) : eq(services.service_group_id, groupId);
const condition = groupId === null ? isNull(services.service_group_id) : eq2(services.service_group_id, groupId);
const row = db.select({ maxOrder: sql2`coalesce(max(${services.sort_order}), -1)` }).from(services).where(condition).get();
return row?.maxOrder ?? -1;
}
@@ -599,13 +700,13 @@ function listServices(db) {
return db.select().from(services).orderBy(asc(services.sort_order), asc(services.name)).all();
}
function listServicesByGroup(db, groupId) {
return db.select().from(services).where(eq(services.service_group_id, groupId)).orderBy(asc(services.sort_order), asc(services.name)).all();
return db.select().from(services).where(eq2(services.service_group_id, groupId)).orderBy(asc(services.sort_order), asc(services.name)).all();
}
function listUngroupedServices(db) {
return db.select().from(services).where(isNull(services.service_group_id)).orderBy(asc(services.sort_order), asc(services.name)).all();
}
function getService(db, id) {
const row = db.select().from(services).where(eq(services.id, id)).get();
const row = db.select().from(services).where(eq2(services.id, id)).get();
if (!row) throw new NotFoundError(`service ${id}`);
return row;
}
@@ -620,11 +721,11 @@ function updateService(db, id, name, slug) {
slug,
subdomain: slug,
updated_at: sql2`datetime('now')`
}).where(eq(services.id, id)).run();
}).where(eq2(services.id, id)).run();
return getService(db, id);
}
function setServiceEnabled(db, id, enabled) {
db.update(services).set({ enabled, updated_at: sql2`datetime('now')` }).where(eq(services.id, id)).run();
db.update(services).set({ enabled, updated_at: sql2`datetime('now')` }).where(eq2(services.id, id)).run();
return getService(db, id);
}
function setServiceLb(db, id, weight, priority) {
@@ -632,7 +733,7 @@ function setServiceLb(db, id, weight, priority) {
lb_weight: weight,
lb_priority: priority,
updated_at: sql2`datetime('now')`
}).where(eq(services.id, id)).run();
}).where(eq2(services.id, id)).run();
}
function setServiceGroup(db, id, groupId) {
const sortOrder = maxSortOrderInGroup(db, groupId) + 1;
@@ -640,14 +741,14 @@ function setServiceGroup(db, id, groupId) {
service_group_id: groupId,
sort_order: sortOrder,
updated_at: sql2`datetime('now')`
}).where(eq(services.id, id)).run();
}).where(eq2(services.id, id)).run();
}
function reorderServices(db, groupId, orderedIds) {
const uniqueIds = new Set(orderedIds);
if (uniqueIds.size !== orderedIds.length) {
throw new Error("duplicate service ids in reorder request");
}
const condition = groupId === null ? isNull(services.service_group_id) : eq(services.service_group_id, groupId);
const condition = groupId === null ? isNull(services.service_group_id) : eq2(services.service_group_id, groupId);
const existing = db.select({ id: services.id }).from(services).where(condition).all().map((row) => row.id);
const existingSet = new Set(existing);
for (const serviceId of orderedIds) {
@@ -657,12 +758,12 @@ function reorderServices(db, groupId, orderedIds) {
}
db.transaction((tx) => {
for (let index = 0; index < orderedIds.length; index++) {
tx.update(services).set({ sort_order: index, updated_at: sql2`datetime('now')` }).where(eq(services.id, orderedIds[index])).run();
tx.update(services).set({ sort_order: index, updated_at: sql2`datetime('now')` }).where(eq2(services.id, orderedIds[index])).run();
}
});
}
function deleteService(db, id) {
const result = db.delete(services).where(eq(services.id, id)).run();
const result = db.delete(services).where(eq2(services.id, id)).run();
if (result.changes === 0) throw new NotFoundError(`service ${id}`);
}
function mapServiceGroup(row) {
@@ -689,7 +790,7 @@ function listServiceGroups(db) {
return db.select().from(serviceGroups).orderBy(asc(serviceGroups.name)).all().map(mapServiceGroup);
}
function getServiceGroup(db, id) {
const row = db.select().from(serviceGroups).where(eq(serviceGroups.id, id)).get();
const row = db.select().from(serviceGroups).where(eq2(serviceGroups.id, id)).get();
if (!row) throw new NotFoundError(`service group ${id}`);
return mapServiceGroup(row);
}
@@ -735,37 +836,37 @@ function updateServiceGroup(db, id, name, groupType, icon, domain, lbPatch) {
if (lbPatch.health_check_timeout_ms !== void 0)
update.health_check_timeout_ms = lbPatch.health_check_timeout_ms;
}
const result = db.update(serviceGroups).set(update).where(eq(serviceGroups.id, id)).run();
const result = db.update(serviceGroups).set(update).where(eq2(serviceGroups.id, id)).run();
if (result.changes === 0) throw new NotFoundError(`service group ${id}`);
return getServiceGroup(db, id);
}
function setServiceGroupEnabled(db, id, enabled) {
const result = db.update(serviceGroups).set({ enabled, updated_at: sql2`datetime('now')` }).where(eq(serviceGroups.id, id)).run();
const result = db.update(serviceGroups).set({ enabled, updated_at: sql2`datetime('now')` }).where(eq2(serviceGroups.id, id)).run();
if (result.changes === 0) throw new NotFoundError(`service group ${id}`);
return getServiceGroup(db, id);
}
function deleteServiceGroup(db, id) {
const result = db.delete(serviceGroups).where(eq(serviceGroups.id, id)).run();
const result = db.delete(serviceGroups).where(eq2(serviceGroups.id, id)).run();
if (result.changes === 0) throw new NotFoundError(`service group ${id}`);
}
function listServiceIps(db, serviceId) {
return db.select({ ip: serviceIps.ip }).from(serviceIps).where(eq(serviceIps.service_id, serviceId)).all().map((r) => r.ip);
return db.select({ ip: serviceIps.ip }).from(serviceIps).where(eq2(serviceIps.service_id, serviceId)).all().map((r) => r.ip);
}
function replaceServiceIps(db, serviceId, ips) {
db.delete(serviceIps).where(eq(serviceIps.service_id, serviceId)).run();
db.delete(serviceIps).where(eq2(serviceIps.service_id, serviceId)).run();
for (const ip of ips) {
db.insert(serviceIps).values({ service_id: serviceId, ip }).run();
}
}
function listBindingIps(db, bindingId) {
return db.select({ ip: serviceBindingIps.ip }).from(serviceBindingIps).where(eq(serviceBindingIps.binding_id, bindingId)).all().map((r) => r.ip);
return db.select({ ip: serviceBindingIps.ip }).from(serviceBindingIps).where(eq2(serviceBindingIps.binding_id, bindingId)).all().map((r) => r.ip);
}
function listBindingIpsWithMeta(db, bindingId) {
return db.select({
ip: serviceBindingIps.ip,
weight: serviceBindingIps.weight,
priority: serviceBindingIps.priority
}).from(serviceBindingIps).where(eq(serviceBindingIps.binding_id, bindingId)).all();
}).from(serviceBindingIps).where(eq2(serviceBindingIps.binding_id, bindingId)).all();
}
function replaceBindingIps(db, bindingId, ips) {
replaceBindingIpsWithMeta(
@@ -775,7 +876,7 @@ function replaceBindingIps(db, bindingId, ips) {
);
}
function replaceBindingIpsWithMeta(db, bindingId, entries) {
db.delete(serviceBindingIps).where(eq(serviceBindingIps.binding_id, bindingId)).run();
db.delete(serviceBindingIps).where(eq2(serviceBindingIps.binding_id, bindingId)).run();
for (const entry of entries) {
db.insert(serviceBindingIps).values({
binding_id: bindingId,
@@ -804,13 +905,13 @@ function updateBindingLbConfig(db, bindingId, patch) {
update.health_check_interval_sec = patch.health_check_interval_sec;
if (patch.health_check_timeout_ms !== void 0)
update.health_check_timeout_ms = patch.health_check_timeout_ms;
db.update(serviceBindings).set(update).where(eq(serviceBindings.id, bindingId)).run();
db.update(serviceBindings).set(update).where(eq2(serviceBindings.id, bindingId)).run();
}
function setBindingCnameTarget(db, bindingId, target) {
db.update(serviceBindings).set({
cname_target: target,
updated_at: sql2`datetime('now')`
}).where(eq(serviceBindings.id, bindingId)).run();
}).where(eq2(serviceBindings.id, bindingId)).run();
}
function listRecordsForBinding(db, bindingId) {
return db.all(sql2`
@@ -829,8 +930,8 @@ function linkBindingRecord(db, bindingId, dnsRecordId) {
function unlinkBindingRecord(db, bindingId, dnsRecordId) {
db.delete(serviceBindingRecords).where(
and(
eq(serviceBindingRecords.binding_id, bindingId),
eq(serviceBindingRecords.dns_record_id, dnsRecordId)
eq2(serviceBindingRecords.binding_id, bindingId),
eq2(serviceBindingRecords.dns_record_id, dnsRecordId)
)
).run();
}
@@ -851,8 +952,8 @@ function linkGroupDnsRecord(db, groupId, dnsRecordId) {
function unlinkGroupDnsRecord(db, groupId, dnsRecordId) {
db.delete(serviceGroupDnsRecords).where(
and(
eq(serviceGroupDnsRecords.group_id, groupId),
eq(serviceGroupDnsRecords.dns_record_id, dnsRecordId)
eq2(serviceGroupDnsRecords.group_id, groupId),
eq2(serviceGroupDnsRecords.dns_record_id, dnsRecordId)
)
).run();
}
@@ -942,7 +1043,7 @@ function listBindingsByService(db, serviceId) {
`);
}
function getBinding(db, id) {
const row = db.select().from(serviceBindings).where(eq(serviceBindings.id, id)).get();
const row = db.select().from(serviceBindings).where(eq2(serviceBindings.id, id)).get();
if (!row) throw new NotFoundError(`service binding ${id}`);
return row;
}
@@ -962,9 +1063,9 @@ function getBindingView(db, id) {
function findBinding(db, serviceId, domainId, hostname) {
const row = db.select().from(serviceBindings).where(
and(
eq(serviceBindings.service_id, serviceId),
eq(serviceBindings.domain_id, domainId),
eq(serviceBindings.hostname, hostname)
eq2(serviceBindings.service_id, serviceId),
eq2(serviceBindings.domain_id, domainId),
eq2(serviceBindings.hostname, hostname)
)
).get();
return row ?? null;
@@ -984,43 +1085,43 @@ function updateBindingFields(db, id, serviceId, hostname, dnsRecordId) {
hostname,
dns_record_id: dnsRecordId,
updated_at: sql2`datetime('now')`
}).where(eq(serviceBindings.id, id)).run();
}).where(eq2(serviceBindings.id, id)).run();
}
function setBindingDnsRecordId(db, bindingId, dnsRecordId) {
db.update(serviceBindings).set({
dns_record_id: dnsRecordId,
updated_at: sql2`datetime('now')`
}).where(eq(serviceBindings.id, bindingId)).run();
}).where(eq2(serviceBindings.id, bindingId)).run();
}
function bindingsToRemove(db, serviceId, keepIds) {
const all = db.select().from(serviceBindings).where(eq(serviceBindings.service_id, serviceId)).all();
const all = db.select().from(serviceBindings).where(eq2(serviceBindings.service_id, serviceId)).all();
return all.filter((b) => !keepIds.includes(b.id));
}
function deleteBindingsExcept(db, serviceId, keepIds) {
const all = db.select().from(serviceBindings).where(eq(serviceBindings.service_id, serviceId)).all();
const all = db.select().from(serviceBindings).where(eq2(serviceBindings.service_id, serviceId)).all();
for (const binding of all) {
if (!keepIds.includes(binding.id)) {
db.delete(serviceBindings).where(eq(serviceBindings.id, binding.id)).run();
db.delete(serviceBindings).where(eq2(serviceBindings.id, binding.id)).run();
}
}
}
function deleteBinding(db, id) {
const result = db.delete(serviceBindings).where(eq(serviceBindings.id, id)).run();
const result = db.delete(serviceBindings).where(eq2(serviceBindings.id, id)).run();
if (result.changes === 0) throw new NotFoundError(`service binding ${id}`);
}
function listCertificates(db, status) {
if (status) {
return db.select().from(certificates).where(eq(certificates.status, status)).orderBy(asc(certificates.expires_at)).all();
return db.select().from(certificates).where(eq2(certificates.status, status)).orderBy(asc(certificates.expires_at)).all();
}
return db.select().from(certificates).orderBy(asc(certificates.expires_at)).all();
}
function getCertificate(db, id) {
const row = db.select().from(certificates).where(eq(certificates.id, id)).get();
const row = db.select().from(certificates).where(eq2(certificates.id, id)).get();
if (!row) throw new NotFoundError(`certificate ${id}`);
return row;
}
function upsertCertificateCheck(db, domainId, subdomainId, hostname, expiresAt, status, lastError) {
const existing = db.select().from(certificates).where(eq(certificates.hostname, hostname)).get();
const existing = db.select().from(certificates).where(eq2(certificates.hostname, hostname)).get();
if (existing) {
db.update(certificates).set({
domain_id: domainId,
@@ -1030,7 +1131,7 @@ function upsertCertificateCheck(db, domainId, subdomainId, hostname, expiresAt,
last_error: lastError,
status,
updated_at: sql2`datetime('now')`
}).where(eq(certificates.id, existing.id)).run();
}).where(eq2(certificates.id, existing.id)).run();
return getCertificate(db, existing.id);
}
const id = db.insert(certificates).values({
@@ -1063,7 +1164,7 @@ function createSyncJob(db, id, domainId) {
db.insert(syncJobs).values({ id, domain_id: domainId, status: "pending" }).run();
}
function getSyncJob(db, id) {
const row = db.select().from(syncJobs).where(eq(syncJobs.id, id)).get();
const row = db.select().from(syncJobs).where(eq2(syncJobs.id, id)).get();
if (!row) throw new NotFoundError(`sync job ${id}`);
return row;
}
@@ -1072,7 +1173,7 @@ function finishSyncJob(db, id, status, message) {
status,
message,
finished_at: sql2`datetime('now')`
}).where(eq(syncJobs.id, id)).run();
}).where(eq2(syncJobs.id, id)).run();
}
function listIpHealthStatus(db, scope, refId) {
return db.all(sql2`
@@ -1111,17 +1212,17 @@ function upsertIpHealthStatus(db, scope, refId, ip, status, latencyMs, consecuti
function deleteIpHealthStatusForRef(db, scope, refId) {
db.delete(ipHealthStatus).where(
and(
eq(ipHealthStatus.scope, scope),
eq(ipHealthStatus.ref_id, refId)
eq2(ipHealthStatus.scope, scope),
eq2(ipHealthStatus.ref_id, refId)
)
).run();
}
function deleteIpHealthStatusForIp(db, scope, refId, ip) {
db.delete(ipHealthStatus).where(
and(
eq(ipHealthStatus.scope, scope),
eq(ipHealthStatus.ref_id, refId),
eq(ipHealthStatus.ip, ip)
eq2(ipHealthStatus.scope, scope),
eq2(ipHealthStatus.ref_id, refId),
eq2(ipHealthStatus.ip, ip)
)
).run();
}
@@ -1222,11 +1323,15 @@ function listHealthCheckTargets(db) {
export {
ConflictError,
NotFoundError,
appSettings,
certificates,
createDb,
createMemoryDb,
dnsRecords,
domains,
getAppSettings,
getAppSettingsSecrets,
getAppSwitcher,
groups,
healthCheck,
ipHealthStatus,
@@ -1242,5 +1347,7 @@ export {
serviceIps,
services,
subdomains,
syncJobs
syncJobs,
touchVpsTrackerSync,
updateAppSettings
};
@@ -0,0 +1,13 @@
-- Application settings (single row)
CREATE TABLE IF NOT EXISTS app_settings (
id TEXT PRIMARY KEY,
app_switcher_json TEXT,
vps_tracker_url TEXT,
vps_tracker_integration_token TEXT,
vps_tracker_sync_enabled INTEGER NOT NULL DEFAULT 0,
vps_tracker_last_sync_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT OR IGNORE INTO app_settings (id) VALUES ('settings-main');
+1
View File
@@ -1,5 +1,6 @@
export * from "./schema.js";
export * from "./client.js";
export * from "./errors.js";
export * from "./settings-repo.js";
export * as repos from "./repos.js";
export type { DnsListFilter, UpdateSubdomainPatch } from "./repos.js";
+20
View File
@@ -268,6 +268,25 @@ export const ipHealthStatus = sqliteTable(
(t) => [primaryKey({ columns: [t.scope, t.ref_id, t.ip] })],
);
export const appSettings = sqliteTable("app_settings", {
id: text("id").primaryKey(),
app_switcher_json: text("app_switcher_json"),
vps_tracker_url: text("vps_tracker_url"),
vps_tracker_integration_token: text("vps_tracker_integration_token"),
vps_tracker_sync_enabled: integer("vps_tracker_sync_enabled", {
mode: "boolean",
})
.notNull()
.default(false),
vps_tracker_last_sync_at: text("vps_tracker_last_sync_at"),
created_at: text("created_at")
.notNull()
.default(sql`datetime('now')`),
updated_at: text("updated_at")
.notNull()
.default(sql`datetime('now')`),
});
export const schema = {
groups,
services,
@@ -283,4 +302,5 @@ export const schema = {
certificates,
syncJobs,
ipHealthStatus,
appSettings,
};
+155
View File
@@ -0,0 +1,155 @@
import { eq } from "drizzle-orm";
import { appSwitcherConfigSchema, type AppSwitcherConfig } from "@cfdm/shared";
import type { Db } from "./client.js";
import { appSettings } from "./schema.js";
const SETTINGS_ID = "settings-main";
const DEFAULT_APP_SWITCHER: AppSwitcherConfig = {
menuLabel: "Приложения",
apps: [
{
id: "vps-tracker",
name: "VPS Tracker",
subtitle: "Учёт виртуальных серверов",
url: "http://192.168.100.67:3001",
icon: "server",
shortcut: "⌘1",
},
{
id: "cfdm",
name: "CF Domain Manager",
subtitle: "Управление доменами",
url: "http://192.168.100.67:6363",
icon: "cloud",
shortcut: "⌘2",
},
],
};
export type AppSettingsDto = {
id: string;
appSwitcher: AppSwitcherConfig;
vpsTrackerUrl: string;
vpsTrackerIntegrationTokenSet: boolean;
vpsTrackerSyncEnabled: boolean;
vpsTrackerLastSyncAt: string | null;
};
export type AppSettingsPatch = {
appSwitcher?: AppSwitcherConfig;
vpsTrackerUrl?: string;
vpsTrackerIntegrationToken?: string;
vpsTrackerSyncEnabled?: boolean;
};
function parseAppSwitcher(raw: string | null | undefined): AppSwitcherConfig {
if (!raw?.trim()) return DEFAULT_APP_SWITCHER;
try {
return appSwitcherConfigSchema.parse(JSON.parse(raw));
} catch {
return DEFAULT_APP_SWITCHER;
}
}
function toDto(row: typeof appSettings.$inferSelect): AppSettingsDto {
return {
id: row.id,
appSwitcher: parseAppSwitcher(row.app_switcher_json),
vpsTrackerUrl: row.vps_tracker_url?.trim() ?? "",
vpsTrackerIntegrationTokenSet: Boolean(
row.vps_tracker_integration_token?.trim(),
),
vpsTrackerSyncEnabled: Boolean(row.vps_tracker_sync_enabled),
vpsTrackerLastSyncAt: row.vps_tracker_last_sync_at,
};
}
export function getAppSettings(db: Db): AppSettingsDto {
const row = db
.select()
.from(appSettings)
.where(eq(appSettings.id, SETTINGS_ID))
.get();
if (!row) {
db.insert(appSettings).values({ id: SETTINGS_ID }).run();
return toDto(
db.select().from(appSettings).where(eq(appSettings.id, SETTINGS_ID)).get()!,
);
}
return toDto(row);
}
export function getAppSettingsSecrets(db: Db): {
vpsTrackerUrl: string;
vpsTrackerIntegrationToken: string;
vpsTrackerSyncEnabled: boolean;
} {
const row = db
.select()
.from(appSettings)
.where(eq(appSettings.id, SETTINGS_ID))
.get();
return {
vpsTrackerUrl: row?.vps_tracker_url?.trim() ?? "",
vpsTrackerIntegrationToken:
row?.vps_tracker_integration_token?.trim() ?? "",
vpsTrackerSyncEnabled: Boolean(row?.vps_tracker_sync_enabled),
};
}
export function updateAppSettings(db: Db, patch: AppSettingsPatch): AppSettingsDto {
const existing = db
.select()
.from(appSettings)
.where(eq(appSettings.id, SETTINGS_ID))
.get();
if (!existing) {
db.insert(appSettings).values({ id: SETTINGS_ID }).run();
}
const current = db
.select()
.from(appSettings)
.where(eq(appSettings.id, SETTINGS_ID))
.get()!;
db.update(appSettings)
.set({
app_switcher_json:
patch.appSwitcher !== undefined
? JSON.stringify(patch.appSwitcher)
: current.app_switcher_json,
vps_tracker_url:
patch.vpsTrackerUrl !== undefined
? patch.vpsTrackerUrl
: current.vps_tracker_url,
vps_tracker_integration_token:
patch.vpsTrackerIntegrationToken !== undefined &&
patch.vpsTrackerIntegrationToken.trim() !== ""
? patch.vpsTrackerIntegrationToken
: current.vps_tracker_integration_token,
vps_tracker_sync_enabled:
patch.vpsTrackerSyncEnabled !== undefined
? patch.vpsTrackerSyncEnabled
: current.vps_tracker_sync_enabled,
updated_at: new Date().toISOString(),
})
.where(eq(appSettings.id, SETTINGS_ID))
.run();
return getAppSettings(db);
}
export function touchVpsTrackerSync(db: Db): void {
db.update(appSettings)
.set({
vps_tracker_last_sync_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
.where(eq(appSettings.id, SETTINGS_ID))
.run();
}
export function getAppSwitcher(db: Db): AppSwitcherConfig {
return getAppSettings(db).appSwitcher;
}
+104 -1
View File
@@ -1197,4 +1197,107 @@ type CreateDomainInput = z.infer<typeof createDomainSchema>;
type LoginInput = z.infer<typeof loginSchema>;
type CreateDnsRecordInput = z.infer<typeof createDnsRecordSchema>;
export { CERT_ERROR, CERT_EXPIRED, CERT_MONITORING_VALUES, CERT_MONITOR_AUTO, CERT_MONITOR_REQUIRED, CERT_MONITOR_SKIPPED, CERT_OK, CERT_UNKNOWN, CERT_WARNING, type CertMonitoring, type Certificate, type CfDnsRecord, type CfZone, type CreateDnsRecordInput, type CreateDnsRecordPayload, type CreateDomainInput, type CreateGroupInput, type CreateServiceBindingInput, type CreateServiceGroupInput, type CreateServiceInput, type CreateServiceWithConfigInput, type CreateSubdomainInput, type DnsRecord, type Domain, type DomainListItem, type Group, type GroupWithStats, type HealthCheckConfig, type HealthCheckScope, type HealthCheckTarget, type HealthCheckType, type HealthStatusQuery, type IpHealthState, type IpHealthStatus, type JwtClaims, type LbMode, type LoginInput, type LoginRequest, type LoginResponse, type ParsedFqdn, type ReorderServicesInput, SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED, type Service, type ServiceBinding, type ServiceBindingView, type ServiceDomainBinding, type ServiceDomainBindingView, type ServiceGroup, type ServiceGroupView, type ServiceGroupsResponse, type ServiceView, type Subdomain, type SubdomainRecord, type SyncJob, type ToggleEnabledInput, type UpdateDomainInput, type UpdateServiceConfigInput, type UpdateServiceGroupInput, type UpdateSubdomainInput, ValidationError, bindingToFqdn, certMonitoringSchema, certStatusFromExpiry, certificateSchema, createDnsRecordSchema, createDomainSchema, createGroupSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, domainListItemSchema, domainSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, healthCheckConfigSchema, healthCheckScopeSchema, healthCheckTypeSchema, healthStatusQuerySchema, ipHealthStateSchema, ipHealthStatusSchema, isValidIpv4, lbModeSchema, loginSchema, normalizeDnsRecordName, parseFqdn, reorderServicesSchema, serviceBindingSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceSchema, serviceViewSchema, shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, toggleEnabledSchema, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateServiceGroupSchema, updateSubdomainSchema, validateDnsRecord };
declare const appSwitcherIconSchema: z.ZodEnum<{
server: "server";
cloud: "cloud";
globe: "globe";
dashboard: "dashboard";
chart: "chart";
}>;
declare const appSwitcherEntrySchema: z.ZodObject<{
id: z.ZodString;
name: z.ZodString;
subtitle: z.ZodOptional<z.ZodString>;
url: z.ZodString;
icon: z.ZodDefault<z.ZodEnum<{
server: "server";
cloud: "cloud";
globe: "globe";
dashboard: "dashboard";
chart: "chart";
}>>;
shortcut: z.ZodOptional<z.ZodString>;
}, z.core.$strip>;
declare const appSwitcherConfigSchema: z.ZodObject<{
menuLabel: z.ZodDefault<z.ZodString>;
apps: z.ZodArray<z.ZodObject<{
id: z.ZodString;
name: z.ZodString;
subtitle: z.ZodOptional<z.ZodString>;
url: z.ZodString;
icon: z.ZodDefault<z.ZodEnum<{
server: "server";
cloud: "cloud";
globe: "globe";
dashboard: "dashboard";
chart: "chart";
}>>;
shortcut: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
}, z.core.$strip>;
type AppSwitcherEntry = z.infer<typeof appSwitcherEntrySchema>;
type AppSwitcherConfig = z.infer<typeof appSwitcherConfigSchema>;
declare const cfdmBindingSyncItemSchema: z.ZodObject<{
bindingId: z.ZodNumber;
serviceId: z.ZodNumber;
serviceName: z.ZodString;
serviceSlug: z.ZodString;
fqdn: z.ZodString;
zoneName: z.ZodString;
hostname: z.ZodString;
ips: z.ZodArray<z.ZodString>;
deleted: z.ZodOptional<z.ZodBoolean>;
}, z.core.$strip>;
declare const cfdmSyncBindingsBodySchema: z.ZodObject<{
bindings: z.ZodArray<z.ZodObject<{
bindingId: z.ZodNumber;
serviceId: z.ZodNumber;
serviceName: z.ZodString;
serviceSlug: z.ZodString;
fqdn: z.ZodString;
zoneName: z.ZodString;
hostname: z.ZodString;
ips: z.ZodArray<z.ZodString>;
deleted: z.ZodOptional<z.ZodBoolean>;
}, z.core.$strip>>;
}, z.core.$strip>;
type CfdmBindingSyncItem = z.infer<typeof cfdmBindingSyncItemSchema>;
declare const appSettingsPatchSchema: z.ZodObject<{
appSwitcher: z.ZodOptional<z.ZodObject<{
menuLabel: z.ZodDefault<z.ZodString>;
apps: z.ZodArray<z.ZodObject<{
id: z.ZodString;
name: z.ZodString;
subtitle: z.ZodOptional<z.ZodString>;
url: z.ZodString;
icon: z.ZodDefault<z.ZodEnum<{
server: "server";
cloud: "cloud";
globe: "globe";
dashboard: "dashboard";
chart: "chart";
}>>;
shortcut: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
}, z.core.$strip>>;
vpsTrackerUrl: z.ZodOptional<z.ZodUnion<[z.ZodString, z.ZodLiteral<"">]>>;
vpsTrackerIntegrationToken: z.ZodOptional<z.ZodString>;
vpsTrackerSyncEnabled: z.ZodOptional<z.ZodBoolean>;
}, z.core.$strip>;
type AppSettingsPatch = z.infer<typeof appSettingsPatchSchema>;
declare const vpsTrackerEventSchema: z.ZodObject<{
event: z.ZodEnum<{
vps_down: "vps_down";
vps_up: "vps_up";
}>;
vps: z.ZodArray<z.ZodObject<{
id: z.ZodString;
ip: z.ZodOptional<z.ZodString>;
label: z.ZodOptional<z.ZodString>;
}, z.core.$strip>>;
timestamp: z.ZodOptional<z.ZodString>;
}, z.core.$strip>;
type VpsTrackerEvent = z.infer<typeof vpsTrackerEventSchema>;
export { type AppSettingsPatch, type AppSwitcherConfig, type AppSwitcherEntry, CERT_ERROR, CERT_EXPIRED, CERT_MONITORING_VALUES, CERT_MONITOR_AUTO, CERT_MONITOR_REQUIRED, CERT_MONITOR_SKIPPED, CERT_OK, CERT_UNKNOWN, CERT_WARNING, type CertMonitoring, type Certificate, type CfDnsRecord, type CfZone, type CfdmBindingSyncItem, type CreateDnsRecordInput, type CreateDnsRecordPayload, type CreateDomainInput, type CreateGroupInput, type CreateServiceBindingInput, type CreateServiceGroupInput, type CreateServiceInput, type CreateServiceWithConfigInput, type CreateSubdomainInput, type DnsRecord, type Domain, type DomainListItem, type Group, type GroupWithStats, type HealthCheckConfig, type HealthCheckScope, type HealthCheckTarget, type HealthCheckType, type HealthStatusQuery, type IpHealthState, type IpHealthStatus, type JwtClaims, type LbMode, type LoginInput, type LoginRequest, type LoginResponse, type ParsedFqdn, type ReorderServicesInput, SYNC_CONFLICT, SYNC_ERROR, SYNC_PENDING_DELETE, SYNC_PENDING_PUSH, SYNC_SYNCED, type Service, type ServiceBinding, type ServiceBindingView, type ServiceDomainBinding, type ServiceDomainBindingView, type ServiceGroup, type ServiceGroupView, type ServiceGroupsResponse, type ServiceView, type Subdomain, type SubdomainRecord, type SyncJob, type ToggleEnabledInput, type UpdateDomainInput, type UpdateServiceConfigInput, type UpdateServiceGroupInput, type UpdateSubdomainInput, ValidationError, type VpsTrackerEvent, appSettingsPatchSchema, appSwitcherConfigSchema, appSwitcherEntrySchema, appSwitcherIconSchema, bindingToFqdn, certMonitoringSchema, certStatusFromExpiry, certificateSchema, cfdmBindingSyncItemSchema, cfdmSyncBindingsBodySchema, createDnsRecordSchema, createDomainSchema, createGroupSchema, createServiceBindingSchema, createServiceGroupSchema, createServiceSchema, createServiceWithConfigSchema, createSubdomainSchema, dnsNameToSubdomainLabel, dnsRecordNamesMatch, dnsRecordSchema, domainListItemSchema, domainSchema, fqdnToDisplay, groupSchema, groupWithStatsSchema, healthCheckConfigSchema, healthCheckScopeSchema, healthCheckTypeSchema, healthStatusQuerySchema, ipHealthStateSchema, ipHealthStatusSchema, isValidIpv4, lbModeSchema, loginSchema, normalizeDnsRecordName, parseFqdn, reorderServicesSchema, serviceBindingSchema, serviceDomainBindingSchema, serviceGroupSchema, serviceGroupTypeSchema, serviceGroupViewSchema, serviceGroupsResponseSchema, serviceSchema, serviceViewSchema, shouldMonitorService, subdomainLabelToFqdn, subdomainSchema, toggleEnabledSchema, updateDomainGroupSchema, updateDomainSchema, updateServiceConfigSchema, updateServiceGroupSchema, updateSubdomainSchema, validateDnsRecord, vpsTrackerEventSchema };
+64 -1
View File
@@ -478,6 +478,62 @@ var healthStatusQuerySchema = z.object({
scope: healthCheckScopeSchema,
ref_id: z.coerce.number().int().positive()
});
// src/app-switcher.ts
import { z as z2 } from "zod";
var appSwitcherIconSchema = z2.enum([
"server",
"cloud",
"globe",
"dashboard",
"chart"
]);
var appSwitcherEntrySchema = z2.object({
id: z2.string(),
name: z2.string(),
subtitle: z2.string().optional(),
url: z2.string().url(),
icon: appSwitcherIconSchema.default("server"),
shortcut: z2.string().optional()
});
var appSwitcherConfigSchema = z2.object({
menuLabel: z2.string().default("\u041F\u0440\u0438\u043B\u043E\u0436\u0435\u043D\u0438\u044F"),
apps: z2.array(appSwitcherEntrySchema).min(1)
});
// src/integration-vps-tracker.ts
import { z as z3 } from "zod";
var cfdmBindingSyncItemSchema = z3.object({
bindingId: z3.number().int().positive(),
serviceId: z3.number().int().positive(),
serviceName: z3.string().min(1),
serviceSlug: z3.string().min(1),
fqdn: z3.string().min(1),
zoneName: z3.string().min(1),
hostname: z3.string(),
ips: z3.array(z3.string()),
deleted: z3.boolean().optional()
});
var cfdmSyncBindingsBodySchema = z3.object({
bindings: z3.array(cfdmBindingSyncItemSchema).min(1)
});
var appSettingsPatchSchema = z3.object({
appSwitcher: appSwitcherConfigSchema.optional(),
vpsTrackerUrl: z3.string().url().or(z3.literal("")).optional(),
vpsTrackerIntegrationToken: z3.string().optional(),
vpsTrackerSyncEnabled: z3.boolean().optional()
});
var vpsTrackerEventSchema = z3.object({
event: z3.enum(["vps_down", "vps_up"]),
vps: z3.array(
z3.object({
id: z3.string().min(1),
ip: z3.string().optional(),
label: z3.string().optional()
})
),
timestamp: z3.string().datetime().optional()
});
export {
CERT_ERROR,
CERT_EXPIRED,
@@ -494,10 +550,16 @@ export {
SYNC_PENDING_PUSH,
SYNC_SYNCED,
ValidationError,
appSettingsPatchSchema,
appSwitcherConfigSchema,
appSwitcherEntrySchema,
appSwitcherIconSchema,
bindingToFqdn,
certMonitoringSchema,
certStatusFromExpiry,
certificateSchema,
cfdmBindingSyncItemSchema,
cfdmSyncBindingsBodySchema,
createDnsRecordSchema,
createDomainSchema,
createGroupSchema,
@@ -543,5 +605,6 @@ export {
updateServiceConfigSchema,
updateServiceGroupSchema,
updateSubdomainSchema,
validateDnsRecord
validateDnsRecord,
vpsTrackerEventSchema
};
+26
View File
@@ -0,0 +1,26 @@
import { z } from "zod";
export const appSwitcherIconSchema = z.enum([
"server",
"cloud",
"globe",
"dashboard",
"chart",
]);
export const appSwitcherEntrySchema = z.object({
id: z.string(),
name: z.string(),
subtitle: z.string().optional(),
url: z.string().url(),
icon: appSwitcherIconSchema.default("server"),
shortcut: z.string().optional(),
});
export const appSwitcherConfigSchema = z.object({
menuLabel: z.string().default("Приложения"),
apps: z.array(appSwitcherEntrySchema).min(1),
});
export type AppSwitcherEntry = z.infer<typeof appSwitcherEntrySchema>;
export type AppSwitcherConfig = z.infer<typeof appSwitcherConfigSchema>;
+2
View File
@@ -3,6 +3,8 @@ export * from "./validators.js";
export * from "./subdomain.js";
export * from "./parse-fqdn.js";
export * from "./schemas.js";
export * from "./app-switcher.js";
export * from "./integration-vps-tracker.js";
export type {
CfZone,
CfDnsRecord,
@@ -0,0 +1,43 @@
import { z } from "zod";
import { appSwitcherConfigSchema } from "./app-switcher.js";
export const cfdmBindingSyncItemSchema = z.object({
bindingId: z.number().int().positive(),
serviceId: z.number().int().positive(),
serviceName: z.string().min(1),
serviceSlug: z.string().min(1),
fqdn: z.string().min(1),
zoneName: z.string().min(1),
hostname: z.string(),
ips: z.array(z.string()),
deleted: z.boolean().optional(),
});
export const cfdmSyncBindingsBodySchema = z.object({
bindings: z.array(cfdmBindingSyncItemSchema).min(1),
});
export type CfdmBindingSyncItem = z.infer<typeof cfdmBindingSyncItemSchema>;
export const appSettingsPatchSchema = z.object({
appSwitcher: appSwitcherConfigSchema.optional(),
vpsTrackerUrl: z.string().url().or(z.literal("")).optional(),
vpsTrackerIntegrationToken: z.string().optional(),
vpsTrackerSyncEnabled: z.boolean().optional(),
});
export type AppSettingsPatch = z.infer<typeof appSettingsPatchSchema>;
export const vpsTrackerEventSchema = z.object({
event: z.enum(["vps_down", "vps_up"]),
vps: z.array(
z.object({
id: z.string().min(1),
ip: z.string().optional(),
label: z.string().optional(),
}),
),
timestamp: z.string().datetime().optional(),
});
export type VpsTrackerEvent = z.infer<typeof vpsTrackerEventSchema>;