Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19f6c625f8 | ||
|
|
f063378e6b | ||
|
|
96d754ef26 | ||
|
|
d6e46d30b7 | ||
|
|
654b08c8e5 | ||
|
|
146afc1369 | ||
|
|
ba66755a29 | ||
|
|
25c95356a1 | ||
|
|
9cc6c8d958 |
@@ -3,6 +3,7 @@ import fp from "fastify-plugin";
|
|||||||
import {
|
import {
|
||||||
createDb,
|
createDb,
|
||||||
createMemoryDb,
|
createMemoryDb,
|
||||||
|
ensureCatalogLocations,
|
||||||
healthCheck,
|
healthCheck,
|
||||||
runMigrations,
|
runMigrations,
|
||||||
type Db,
|
type Db,
|
||||||
@@ -31,6 +32,7 @@ async function dbPlugin(
|
|||||||
: createDb(opts.config!.databaseUrl);
|
: createDb(opts.config!.databaseUrl);
|
||||||
|
|
||||||
runMigrations(sqlite);
|
runMigrations(sqlite);
|
||||||
|
ensureCatalogLocations(db);
|
||||||
app.decorate("db", db);
|
app.decorate("db", db);
|
||||||
app.decorate("sqlite", sqlite);
|
app.decorate("sqlite", sqlite);
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
} from "@cdnmanager/db";
|
} from "@cdnmanager/db";
|
||||||
import { AppError } from "../errors.js";
|
import { AppError } from "../errors.js";
|
||||||
import {
|
import {
|
||||||
|
buildDnsPreviewRecords,
|
||||||
buildHostname,
|
buildHostname,
|
||||||
isValidIpv4,
|
isValidIpv4,
|
||||||
isValidIpv6,
|
isValidIpv6,
|
||||||
@@ -322,15 +323,30 @@ export const fleetRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
const loc = listLocations(app.db).find((l) => l.id === q.locationId);
|
const loc = listLocations(app.db).find((l) => l.id === q.locationId);
|
||||||
if (!loc) throw AppError.notFound("location not found");
|
if (!loc) throw AppError.notFound("location not found");
|
||||||
const indexNum = Number(q.indexNum ?? "1") || 1;
|
const indexNum = Number(q.indexNum ?? "1") || 1;
|
||||||
|
const hostname = buildHostname({
|
||||||
|
template: q.template || zone.namingTemplate,
|
||||||
|
locationCode: loc.code,
|
||||||
|
role: q.role,
|
||||||
|
indexNum,
|
||||||
|
zoneName: zone.name,
|
||||||
|
providerTag: q.providerTag,
|
||||||
|
});
|
||||||
|
const aliases = q.nodeId
|
||||||
|
? listAliases(app.db, { zoneId: zone.id }).filter(
|
||||||
|
(a) => a.targetNodeId === q.nodeId,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const records = buildDnsPreviewRecords({
|
||||||
|
hostname,
|
||||||
|
ttl: zone.defaultTtl,
|
||||||
|
ipv4: q.ipv4,
|
||||||
|
ipv6: q.ipv6,
|
||||||
|
aliases: aliases.map((a) => ({ name: a.name, purpose: a.purpose })),
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
hostname: buildHostname({
|
hostname,
|
||||||
template: q.template || zone.namingTemplate,
|
ttl: zone.defaultTtl,
|
||||||
locationCode: loc.code,
|
records,
|
||||||
role: q.role,
|
|
||||||
indexNum,
|
|
||||||
zoneName: zone.name,
|
|
||||||
providerTag: q.providerTag,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,6 +24,56 @@ export function buildHostname(opts: {
|
|||||||
return host.replace(/\.$/, "");
|
return host.replace(/\.$/, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DnsPreviewRecord = {
|
||||||
|
type: "A" | "AAAA" | "CNAME";
|
||||||
|
name: string;
|
||||||
|
content: string;
|
||||||
|
ttl: number;
|
||||||
|
/** purpose / note (e.g. alias purpose) */
|
||||||
|
note?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Desired-state DNS for node form preview (A/AAAA + CNAME aliases → hostname). */
|
||||||
|
export function buildDnsPreviewRecords(opts: {
|
||||||
|
hostname: string;
|
||||||
|
ttl: number;
|
||||||
|
ipv4?: string | null;
|
||||||
|
ipv6?: string | null;
|
||||||
|
aliases?: Array<{ name: string; purpose?: string | null }>;
|
||||||
|
}): DnsPreviewRecord[] {
|
||||||
|
const records: DnsPreviewRecord[] = [];
|
||||||
|
const ipv4 = opts.ipv4?.trim();
|
||||||
|
const ipv6 = opts.ipv6?.trim();
|
||||||
|
if (ipv4) {
|
||||||
|
records.push({
|
||||||
|
type: "A",
|
||||||
|
name: opts.hostname,
|
||||||
|
content: ipv4,
|
||||||
|
ttl: opts.ttl,
|
||||||
|
note: "dns-only",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (ipv6) {
|
||||||
|
records.push({
|
||||||
|
type: "AAAA",
|
||||||
|
name: opts.hostname,
|
||||||
|
content: ipv6,
|
||||||
|
ttl: opts.ttl,
|
||||||
|
note: "dns-only",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const alias of opts.aliases ?? []) {
|
||||||
|
records.push({
|
||||||
|
type: "CNAME",
|
||||||
|
name: alias.name,
|
||||||
|
content: opts.hostname,
|
||||||
|
ttl: opts.ttl,
|
||||||
|
note: alias.purpose ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeFqdn(name: string): string {
|
export function normalizeFqdn(name: string): string {
|
||||||
return name.trim().toLowerCase().replace(/\.$/, "");
|
return name.trim().toLowerCase().replace(/\.$/, "");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,5 +112,44 @@ describe("fleet api", () => {
|
|||||||
});
|
});
|
||||||
expect(topo.statusCode).toBe(200);
|
expect(topo.statusCode).toBe(200);
|
||||||
expect(topo.json().nodes).toHaveLength(1);
|
expect(topo.json().nodes).toHaveLength(1);
|
||||||
|
|
||||||
|
const preview = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/v1/naming/preview?${new URLSearchParams({
|
||||||
|
zoneId: zone.id,
|
||||||
|
locationId: msk.id,
|
||||||
|
role: "gw",
|
||||||
|
indexNum: "2",
|
||||||
|
ipv4: "198.51.100.10",
|
||||||
|
ipv6: "2001:db8::10",
|
||||||
|
nodeId: node.id,
|
||||||
|
})}`,
|
||||||
|
headers: authHeader,
|
||||||
|
});
|
||||||
|
expect(preview.statusCode).toBe(200);
|
||||||
|
const body = preview.json() as {
|
||||||
|
hostname: string;
|
||||||
|
records: Array<{ type: string; name: string; content: string }>;
|
||||||
|
};
|
||||||
|
expect(body.hostname).toBe("msk-gw02.rtnt.top");
|
||||||
|
expect(body.records).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
type: "A",
|
||||||
|
name: "msk-gw02.rtnt.top",
|
||||||
|
content: "198.51.100.10",
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
type: "AAAA",
|
||||||
|
name: "msk-gw02.rtnt.top",
|
||||||
|
content: "2001:db8::10",
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
type: "CNAME",
|
||||||
|
name: "msk.rtnt.top",
|
||||||
|
content: "msk-gw02.rtnt.top",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import * as React from 'react'
|
||||||
|
import { CheckIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
import { cn } from '@cdnmanager/ui/lib/utils'
|
||||||
|
import {
|
||||||
|
Autocomplete,
|
||||||
|
AutocompleteContent,
|
||||||
|
AutocompleteEmpty,
|
||||||
|
AutocompleteInput,
|
||||||
|
AutocompleteItem,
|
||||||
|
AutocompleteList,
|
||||||
|
} from '@/components/reui/autocomplete'
|
||||||
|
import { TruncatedText } from '@/components/truncated-text'
|
||||||
|
|
||||||
|
export interface AutoCompleteOption {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
/** Левый префикс (например флаг страны). */
|
||||||
|
leading?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AutoCompleteInputProps {
|
||||||
|
id?: string
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
options: AutoCompleteOption[]
|
||||||
|
placeholder?: string
|
||||||
|
searchPlaceholder?: string
|
||||||
|
emptyText?: string
|
||||||
|
className?: string
|
||||||
|
/** Показывать ли выбранный leading в поле (например флаг). */
|
||||||
|
showLeadingInInput?: boolean
|
||||||
|
/** Разрешать ли произвольный ввод (не только из списка). По умолчанию true. */
|
||||||
|
allowFreeText?: boolean
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AutoCompleteInput({
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
placeholder = 'Выбрать…',
|
||||||
|
searchPlaceholder,
|
||||||
|
emptyText = 'Ничего не найдено',
|
||||||
|
className,
|
||||||
|
showLeadingInInput = true,
|
||||||
|
allowFreeText = true,
|
||||||
|
disabled = false,
|
||||||
|
}: AutoCompleteInputProps) {
|
||||||
|
const trimmedValue = value.trim()
|
||||||
|
|
||||||
|
const selected = React.useMemo(
|
||||||
|
() => options.find((o) => o.value.toLowerCase() === trimmedValue.toLowerCase()),
|
||||||
|
[options, trimmedValue],
|
||||||
|
)
|
||||||
|
const leading = showLeadingInInput ? selected?.leading : undefined
|
||||||
|
|
||||||
|
const inputPlaceholder = searchPlaceholder ?? placeholder
|
||||||
|
|
||||||
|
const handleValueChange = React.useCallback(
|
||||||
|
(inputVal: string) => {
|
||||||
|
const q = inputVal.trim()
|
||||||
|
const match = options.find(
|
||||||
|
(o) =>
|
||||||
|
o.label.toLowerCase() === q.toLowerCase() ||
|
||||||
|
o.value.toLowerCase() === q.toLowerCase(),
|
||||||
|
)
|
||||||
|
if (match) {
|
||||||
|
onChange(match.value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (allowFreeText) {
|
||||||
|
onChange(inputVal)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[options, onChange, allowFreeText],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Autocomplete
|
||||||
|
items={options}
|
||||||
|
value={value}
|
||||||
|
onValueChange={handleValueChange}
|
||||||
|
itemToStringValue={(item) => item.label}
|
||||||
|
mode="list"
|
||||||
|
autoHighlight
|
||||||
|
openOnInputClick
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<div className="relative w-full">
|
||||||
|
{leading ? (
|
||||||
|
<span className="pointer-events-none absolute start-2.5 top-1/2 z-10 size-4 -translate-y-1/2 [&_svg]:size-full">
|
||||||
|
{leading}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<AutocompleteInput
|
||||||
|
id={id}
|
||||||
|
placeholder={trimmedValue ? undefined : inputPlaceholder}
|
||||||
|
showTrigger
|
||||||
|
showClear={Boolean(trimmedValue)}
|
||||||
|
className={cn(leading && 'ps-8', className)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<AutocompleteContent>
|
||||||
|
<AutocompleteEmpty>{emptyText}</AutocompleteEmpty>
|
||||||
|
<AutocompleteList>
|
||||||
|
{(item) => {
|
||||||
|
const isSelected = item.value.toLowerCase() === trimmedValue.toLowerCase()
|
||||||
|
return (
|
||||||
|
<AutocompleteItem
|
||||||
|
key={item.value}
|
||||||
|
value={item}
|
||||||
|
className="gap-2.5 px-2 py-1.5"
|
||||||
|
>
|
||||||
|
{item.leading ? (
|
||||||
|
<span className="relative z-1 size-4 shrink-0">{item.leading}</span>
|
||||||
|
) : null}
|
||||||
|
<span className="relative z-1 min-w-0 flex-1">
|
||||||
|
<TruncatedText>{item.label}</TruncatedText>
|
||||||
|
</span>
|
||||||
|
{isSelected ? (
|
||||||
|
<CheckIcon className="relative z-1 size-4 shrink-0 opacity-60" />
|
||||||
|
) : null}
|
||||||
|
</AutocompleteItem>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</AutocompleteList>
|
||||||
|
</AutocompleteContent>
|
||||||
|
</Autocomplete>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { cn } from '@cdnmanager/ui/lib/utils'
|
||||||
|
import { COUNTRY_BY_NAME_RU } from '@cdnmanager/shared'
|
||||||
|
import { countryCodeFromName, getCountryFlagUrl } from '@/lib/country-labels'
|
||||||
|
|
||||||
|
interface CountryFlagProps {
|
||||||
|
code?: string
|
||||||
|
country?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CountryFlag({ code, country, className }: CountryFlagProps) {
|
||||||
|
const resolvedCode =
|
||||||
|
code ??
|
||||||
|
(country
|
||||||
|
? COUNTRY_BY_NAME_RU[country.trim().toLowerCase()]?.code ??
|
||||||
|
countryCodeFromName(country)
|
||||||
|
: undefined)
|
||||||
|
const url = getCountryFlagUrl(resolvedCode)
|
||||||
|
if (!url) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt=""
|
||||||
|
className={cn('size-4 shrink-0 rounded-full object-cover', className)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ async function handoffOnUnauthorized(): Promise<void> {
|
|||||||
redirectToPortalLogin(`${window.location.origin}/auth/callback`)
|
redirectToPortalLogin(`${window.location.origin}/auth/callback`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Match CFDM: on cooldown do not open sso_loop / jwt_rejected — caller handles.
|
||||||
if (!cfg.required && !isAuthEnabled()) {
|
if (!cfg.required && !isAuthEnabled()) {
|
||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,5 +62,11 @@ export function getAppUrl(
|
|||||||
export function getCurrentApp(
|
export function getCurrentApp(
|
||||||
config: AppSwitcherConfig = DEFAULT_APP_SWITCHER_CONFIG,
|
config: AppSwitcherConfig = DEFAULT_APP_SWITCHER_CONFIG,
|
||||||
): AppSwitcherEntry {
|
): AppSwitcherEntry {
|
||||||
return config.apps.find((app) => app.id === CURRENT_APP_ID) ?? config.apps[0]!
|
const current = config.apps.find((app) => app.id === CURRENT_APP_ID)
|
||||||
|
if (current) return current
|
||||||
|
if (config.apps[0]) return config.apps[0]
|
||||||
|
return (
|
||||||
|
DEFAULT_APP_SWITCHER_CONFIG.apps.find((a) => a.id === CURRENT_APP_ID) ??
|
||||||
|
DEFAULT_APP_SWITCHER_CONFIG.apps[0]!
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,6 +146,17 @@ export function redirectToPortalLogin(returnTo?: string): boolean {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interactive portal login without return_to — breaks SSO storms when cooldown
|
||||||
|
* blocks silent handoff (expired portal session / rejected JWT). Same pattern as
|
||||||
|
* VPS Tracker / CFDM for invalid hash tokens.
|
||||||
|
*/
|
||||||
|
export function redirectToPortalLoginInteractive(): void {
|
||||||
|
clearToken()
|
||||||
|
resetPortalHandoff()
|
||||||
|
window.location.assign(authPortalUrl())
|
||||||
|
}
|
||||||
|
|
||||||
/** End portal SSO session (refresh cookie + portal token). Do not pass return_to. */
|
/** End portal SSO session (refresh cookie + portal token). Do not pass return_to. */
|
||||||
export function redirectToPortalLogout(): void {
|
export function redirectToPortalLogout(): void {
|
||||||
clearToken()
|
clearToken()
|
||||||
@@ -196,6 +207,8 @@ export function getClaims(): AccessClaims | null {
|
|||||||
if (!claims) return null
|
if (!claims) return null
|
||||||
if (claims.exp && claims.exp * 1000 < Date.now()) {
|
if (claims.exp && claims.exp * 1000 < Date.now()) {
|
||||||
clearToken()
|
clearToken()
|
||||||
|
// Allow a fresh portal handoff after local JWT expiry.
|
||||||
|
resetPortalHandoff()
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return claims
|
return claims
|
||||||
@@ -255,5 +268,5 @@ export function firstAllowedPath(): string {
|
|||||||
const perm = permissionForPath(path)
|
const perm = permissionForPath(path)
|
||||||
if (!perm || can(perm)) return path
|
if (!perm || can(perm)) return path
|
||||||
}
|
}
|
||||||
return '/'
|
return '/access-denied'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import {
|
||||||
|
COUNTRY_BY_CODE,
|
||||||
|
COUNTRY_BY_NAME_RU,
|
||||||
|
} from '@cdnmanager/shared'
|
||||||
|
|
||||||
|
export function countryNameFromCode(code: string | null | undefined): string {
|
||||||
|
if (!code) return ''
|
||||||
|
return COUNTRY_BY_CODE[code.toUpperCase()]?.name ?? code
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countryCodeFromName(name: string | null | undefined): string {
|
||||||
|
if (!name?.trim()) return ''
|
||||||
|
const trimmed = name.trim()
|
||||||
|
if (trimmed.length === 2) return trimmed.toUpperCase()
|
||||||
|
return COUNTRY_BY_NAME_RU[trimmed.toLowerCase()]?.code ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCountryFlagUrl(code?: string): string | undefined {
|
||||||
|
if (!code || code.length !== 2) return undefined
|
||||||
|
return `https://flagcdn.com/${code.toLowerCase()}.svg`
|
||||||
|
}
|
||||||
@@ -180,6 +180,9 @@ export async function previewHostname(params: {
|
|||||||
role: string
|
role: string
|
||||||
indexNum?: number
|
indexNum?: number
|
||||||
providerTag?: string
|
providerTag?: string
|
||||||
|
ipv4?: string
|
||||||
|
ipv6?: string
|
||||||
|
nodeId?: string
|
||||||
}) {
|
}) {
|
||||||
const p = new URLSearchParams({
|
const p = new URLSearchParams({
|
||||||
zoneId: params.zoneId,
|
zoneId: params.zoneId,
|
||||||
@@ -188,5 +191,18 @@ export async function previewHostname(params: {
|
|||||||
indexNum: String(params.indexNum ?? 1),
|
indexNum: String(params.indexNum ?? 1),
|
||||||
})
|
})
|
||||||
if (params.providerTag) p.set('providerTag', params.providerTag)
|
if (params.providerTag) p.set('providerTag', params.providerTag)
|
||||||
return api.get<{ hostname: string }>(`/api/v1/naming/preview?${p}`)
|
if (params.ipv4) p.set('ipv4', params.ipv4)
|
||||||
|
if (params.ipv6) p.set('ipv6', params.ipv6)
|
||||||
|
if (params.nodeId) p.set('nodeId', params.nodeId)
|
||||||
|
return api.get<{
|
||||||
|
hostname: string
|
||||||
|
ttl: number
|
||||||
|
records: Array<{
|
||||||
|
type: 'A' | 'AAAA' | 'CNAME'
|
||||||
|
name: string
|
||||||
|
content: string
|
||||||
|
ttl: number
|
||||||
|
note?: string
|
||||||
|
}>
|
||||||
|
}>(`/api/v1/naming/preview?${p}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as LoginRouteImport } from './routes/login'
|
import { Route as LoginRouteImport } from './routes/login'
|
||||||
|
import { Route as AccessDeniedRouteImport } from './routes/access-denied'
|
||||||
import { Route as AuthRouteImport } from './routes/_auth'
|
import { Route as AuthRouteImport } from './routes/_auth'
|
||||||
import { Route as AuthIndexRouteImport } from './routes/_auth/index'
|
import { Route as AuthIndexRouteImport } from './routes/_auth/index'
|
||||||
import { Route as AuthCallbackRouteImport } from './routes/auth.callback'
|
import { Route as AuthCallbackRouteImport } from './routes/auth.callback'
|
||||||
@@ -28,6 +29,11 @@ const LoginRoute = LoginRouteImport.update({
|
|||||||
path: '/login',
|
path: '/login',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AccessDeniedRoute = AccessDeniedRouteImport.update({
|
||||||
|
id: '/access-denied',
|
||||||
|
path: '/access-denied',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const AuthRoute = AuthRouteImport.update({
|
const AuthRoute = AuthRouteImport.update({
|
||||||
id: '/_auth',
|
id: '/_auth',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
@@ -91,6 +97,7 @@ const AuthSettingsAppearanceRoute = AuthSettingsAppearanceRouteImport.update({
|
|||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof AuthIndexRoute
|
'/': typeof AuthIndexRoute
|
||||||
|
'/access-denied': typeof AccessDeniedRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/settings': typeof AuthSettingsRouteRouteWithChildren
|
'/settings': typeof AuthSettingsRouteRouteWithChildren
|
||||||
'/aliases': typeof AuthAliasesRoute
|
'/aliases': typeof AuthAliasesRoute
|
||||||
@@ -104,6 +111,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/settings/': typeof AuthSettingsIndexRoute
|
'/settings/': typeof AuthSettingsIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
|
'/access-denied': typeof AccessDeniedRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/aliases': typeof AuthAliasesRoute
|
'/aliases': typeof AuthAliasesRoute
|
||||||
'/nodes': typeof AuthNodesRoute
|
'/nodes': typeof AuthNodesRoute
|
||||||
@@ -119,6 +127,7 @@ export interface FileRoutesByTo {
|
|||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/_auth': typeof AuthRouteWithChildren
|
'/_auth': typeof AuthRouteWithChildren
|
||||||
|
'/access-denied': typeof AccessDeniedRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/_auth/settings': typeof AuthSettingsRouteRouteWithChildren
|
'/_auth/settings': typeof AuthSettingsRouteRouteWithChildren
|
||||||
'/_auth/aliases': typeof AuthAliasesRoute
|
'/_auth/aliases': typeof AuthAliasesRoute
|
||||||
@@ -136,6 +145,7 @@ export interface FileRouteTypes {
|
|||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths:
|
fullPaths:
|
||||||
| '/'
|
| '/'
|
||||||
|
| '/access-denied'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/settings'
|
| '/settings'
|
||||||
| '/aliases'
|
| '/aliases'
|
||||||
@@ -149,6 +159,7 @@ export interface FileRouteTypes {
|
|||||||
| '/settings/'
|
| '/settings/'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
|
| '/access-denied'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/aliases'
|
| '/aliases'
|
||||||
| '/nodes'
|
| '/nodes'
|
||||||
@@ -163,6 +174,7 @@ export interface FileRouteTypes {
|
|||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/_auth'
|
| '/_auth'
|
||||||
|
| '/access-denied'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/_auth/settings'
|
| '/_auth/settings'
|
||||||
| '/_auth/aliases'
|
| '/_auth/aliases'
|
||||||
@@ -179,6 +191,7 @@ export interface FileRouteTypes {
|
|||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
AuthRoute: typeof AuthRouteWithChildren
|
AuthRoute: typeof AuthRouteWithChildren
|
||||||
|
AccessDeniedRoute: typeof AccessDeniedRoute
|
||||||
LoginRoute: typeof LoginRoute
|
LoginRoute: typeof LoginRoute
|
||||||
AuthCallbackRoute: typeof AuthCallbackRoute
|
AuthCallbackRoute: typeof AuthCallbackRoute
|
||||||
}
|
}
|
||||||
@@ -192,6 +205,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof LoginRouteImport
|
preLoaderRoute: typeof LoginRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/access-denied': {
|
||||||
|
id: '/access-denied'
|
||||||
|
path: '/access-denied'
|
||||||
|
fullPath: '/access-denied'
|
||||||
|
preLoaderRoute: typeof AccessDeniedRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/_auth': {
|
'/_auth': {
|
||||||
id: '/_auth'
|
id: '/_auth'
|
||||||
path: ''
|
path: ''
|
||||||
@@ -318,6 +338,7 @@ const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
|
|||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
AuthRoute: AuthRouteWithChildren,
|
AuthRoute: AuthRouteWithChildren,
|
||||||
|
AccessDeniedRoute: AccessDeniedRoute,
|
||||||
LoginRoute: LoginRoute,
|
LoginRoute: LoginRoute,
|
||||||
AuthCallbackRoute: AuthCallbackRoute,
|
AuthCallbackRoute: AuthCallbackRoute,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
getClaims,
|
getClaims,
|
||||||
getToken,
|
getToken,
|
||||||
redirectToPortalLogin,
|
redirectToPortalLogin,
|
||||||
|
redirectToPortalLoginInteractive,
|
||||||
} from '@/lib/auth'
|
} from '@/lib/auth'
|
||||||
|
|
||||||
export interface RouterContext {
|
export interface RouterContext {
|
||||||
@@ -16,7 +17,8 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
|||||||
beforeLoad: async ({ location }) => {
|
beforeLoad: async ({ location }) => {
|
||||||
const isLogin = location.pathname === '/login'
|
const isLogin = location.pathname === '/login'
|
||||||
const isCallback = location.pathname === '/auth/callback'
|
const isCallback = location.pathname === '/auth/callback'
|
||||||
if (isCallback) return
|
const isAccessDenied = location.pathname === '/access-denied'
|
||||||
|
if (isCallback || isAccessDenied) return
|
||||||
|
|
||||||
const cfg = await ensureAuthConfig()
|
const cfg = await ensureAuthConfig()
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
@@ -27,12 +29,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
|||||||
const ok = redirectToPortalLogin(
|
const ok = redirectToPortalLogin(
|
||||||
`${window.location.origin}/auth/callback`,
|
`${window.location.origin}/auth/callback`,
|
||||||
)
|
)
|
||||||
if (!ok) {
|
if (!ok) redirectToPortalLoginInteractive()
|
||||||
throw redirect({
|
|
||||||
to: '/auth/callback',
|
|
||||||
search: { error: 'sso_loop' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
await new Promise(() => {})
|
await new Promise(() => {})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -40,12 +37,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
|||||||
const ok = redirectToPortalLogin(
|
const ok = redirectToPortalLogin(
|
||||||
`${window.location.origin}/auth/callback`,
|
`${window.location.origin}/auth/callback`,
|
||||||
)
|
)
|
||||||
if (!ok) {
|
if (!ok) redirectToPortalLoginInteractive()
|
||||||
throw redirect({
|
|
||||||
to: '/auth/callback',
|
|
||||||
search: { error: 'sso_loop' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
await new Promise(() => {})
|
await new Promise(() => {})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
getToken,
|
getToken,
|
||||||
permissionForPath,
|
permissionForPath,
|
||||||
redirectToPortalLogin,
|
redirectToPortalLogin,
|
||||||
|
redirectToPortalLoginInteractive,
|
||||||
} from '@/lib/auth'
|
} from '@/lib/auth'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth')({
|
export const Route = createFileRoute('/_auth')({
|
||||||
@@ -21,25 +22,23 @@ export const Route = createFileRoute('/_auth')({
|
|||||||
const ok = redirectToPortalLogin(
|
const ok = redirectToPortalLogin(
|
||||||
`${window.location.origin}/auth/callback`,
|
`${window.location.origin}/auth/callback`,
|
||||||
)
|
)
|
||||||
if (!ok) {
|
if (!ok) redirectToPortalLoginInteractive()
|
||||||
throw redirect({
|
|
||||||
to: '/auth/callback',
|
|
||||||
search: { error: 'sso_loop' },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
await new Promise(() => {})
|
await new Promise(() => {})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// NEVER redirect to `/` here — `/` is under `_auth` and causes an infinite loop
|
||||||
|
// (browser: «Страница не отвечает»).
|
||||||
if (!claims.apps.includes('cdn')) {
|
if (!claims.apps.includes('cdn')) {
|
||||||
throw redirect({ to: '/' })
|
throw redirect({ to: '/access-denied' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const perm = permissionForPath(location.pathname)
|
const perm = permissionForPath(location.pathname)
|
||||||
if (perm && !can(perm)) {
|
if (perm && !can(perm)) {
|
||||||
const fallback = firstAllowedPath()
|
const fallback = firstAllowedPath()
|
||||||
if (fallback !== location.pathname) {
|
if (fallback === '/access-denied' || fallback === location.pathname) {
|
||||||
throw redirect({ to: fallback as '/' })
|
throw redirect({ to: '/access-denied' })
|
||||||
}
|
}
|
||||||
|
throw redirect({ to: fallback as '/' })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
component: () => (
|
component: () => (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
import { useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
@@ -8,13 +8,19 @@ import type { ColumnDef } from '@tanstack/react-table'
|
|||||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||||
import {
|
import {
|
||||||
CloudIcon,
|
CloudIcon,
|
||||||
MapPinIcon,
|
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
RefreshCwIcon,
|
RefreshCwIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import type { Node, NodeRole } from '@cdnmanager/shared'
|
import type { Node, NodeRole } from '@cdnmanager/shared'
|
||||||
|
import {
|
||||||
|
COUNTRIES,
|
||||||
|
COUNTRY_BY_NAME_RU,
|
||||||
|
buildCityOptions,
|
||||||
|
cityMatchesCountry,
|
||||||
|
resolveCountryForCityFromRows,
|
||||||
|
} from '@cdnmanager/shared'
|
||||||
import { PageShell } from '@/components/page-shell'
|
import { PageShell } from '@/components/page-shell'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { ResourcePage } from '@/components/reui-kit'
|
import { ResourcePage } from '@/components/reui-kit'
|
||||||
@@ -25,6 +31,13 @@ import { Button } from '@cdnmanager/ui/components/button'
|
|||||||
import { Input } from '@cdnmanager/ui/components/input'
|
import { Input } from '@cdnmanager/ui/components/input'
|
||||||
import { Label } from '@cdnmanager/ui/components/label'
|
import { Label } from '@cdnmanager/ui/components/label'
|
||||||
import { SelectField } from '@/components/select-field'
|
import { SelectField } from '@/components/select-field'
|
||||||
|
import { AutoCompleteInput } from '@/components/auto-complete-input'
|
||||||
|
import { CountryFlag } from '@/components/country-flag'
|
||||||
|
import { FormFieldSimple } from '@/components/form-field'
|
||||||
|
import {
|
||||||
|
countryCodeFromName,
|
||||||
|
countryNameFromCode,
|
||||||
|
} from '@/lib/country-labels'
|
||||||
import { queryClient } from '@/lib/query-client'
|
import { queryClient } from '@/lib/query-client'
|
||||||
import {
|
import {
|
||||||
createNode,
|
createNode,
|
||||||
@@ -61,12 +74,16 @@ const formSchema = z.object({
|
|||||||
type FormValues = z.infer<typeof formSchema>
|
type FormValues = z.infer<typeof formSchema>
|
||||||
|
|
||||||
const ROLES: { value: NodeRole; label: string }[] = [
|
const ROLES: { value: NodeRole; label: string }[] = [
|
||||||
{ value: 'hub', label: 'hub' },
|
{ value: 'hub', label: 'Hub' },
|
||||||
{ value: 'gw', label: 'gw' },
|
{ value: 'gw', label: 'Gateway' },
|
||||||
{ value: 'edge', label: 'edge' },
|
{ value: 'edge', label: 'Edge' },
|
||||||
{ value: 'ix', label: 'ix' },
|
{ value: 'ix', label: 'IX' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const ROLE_LABEL: Record<NodeRole, string> = Object.fromEntries(
|
||||||
|
ROLES.map((r) => [r.value, r.label]),
|
||||||
|
) as Record<NodeRole, string>
|
||||||
|
|
||||||
function NodesPage() {
|
function NodesPage() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const { data: nodes = [], isLoading, isError, error, refetch } = useQuery(
|
const { data: nodes = [], isLoading, isError, error, refetch } = useQuery(
|
||||||
@@ -79,7 +96,18 @@ function NodesPage() {
|
|||||||
const [sheetOpen, setSheetOpen] = useState(false)
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
const [editing, setEditing] = useState<Node | null>(null)
|
const [editing, setEditing] = useState<Node | null>(null)
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||||
const [preview, setPreview] = useState('')
|
const [previewHost, setPreviewHost] = useState('')
|
||||||
|
const [previewRecords, setPreviewRecords] = useState<
|
||||||
|
Array<{
|
||||||
|
type: 'A' | 'AAAA' | 'CNAME'
|
||||||
|
name: string
|
||||||
|
content: string
|
||||||
|
ttl: number
|
||||||
|
note?: string
|
||||||
|
}>
|
||||||
|
>([])
|
||||||
|
const [countryName, setCountryName] = useState('')
|
||||||
|
const [locationQuery, setLocationQuery] = useState('')
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
@@ -101,23 +129,122 @@ function NodesPage() {
|
|||||||
const watchRole = form.watch('role')
|
const watchRole = form.watch('role')
|
||||||
const watchIndex = form.watch('indexNum')
|
const watchIndex = form.watch('indexNum')
|
||||||
const watchProvider = form.watch('providerTag')
|
const watchProvider = form.watch('providerTag')
|
||||||
|
const watchIpv4 = form.watch('ipv4')
|
||||||
|
const watchIpv6 = form.watch('ipv6')
|
||||||
|
|
||||||
async function refreshPreview() {
|
const countryCode = countryCodeFromName(countryName)
|
||||||
if (!watchZone || !watchLoc || !watchRole) return
|
|
||||||
|
const locationRowsForGeo = useMemo(
|
||||||
|
() =>
|
||||||
|
locations.map((l) => ({
|
||||||
|
city: l.name,
|
||||||
|
country: countryNameFromCode(l.country),
|
||||||
|
})),
|
||||||
|
[locations],
|
||||||
|
)
|
||||||
|
|
||||||
|
const countryOptions = useMemo(() => {
|
||||||
|
const names = new Set(COUNTRIES.map((c) => c.name))
|
||||||
|
for (const loc of locations) {
|
||||||
|
const n = countryNameFromCode(loc.country)
|
||||||
|
if (n) names.add(n)
|
||||||
|
}
|
||||||
|
return [...names]
|
||||||
|
.sort((a, b) => a.localeCompare(b, 'ru'))
|
||||||
|
.map((name) => {
|
||||||
|
const ref = COUNTRY_BY_NAME_RU[name.toLowerCase()]
|
||||||
|
return {
|
||||||
|
value: name,
|
||||||
|
label: name,
|
||||||
|
leading: <CountryFlag code={ref?.code} country={name} />,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [locations])
|
||||||
|
|
||||||
|
const cityOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
buildCityOptions(locationRowsForGeo, countryName.trim() || undefined, {
|
||||||
|
includeCatalog: true,
|
||||||
|
}),
|
||||||
|
[locationRowsForGeo, countryName],
|
||||||
|
)
|
||||||
|
|
||||||
|
function locationIdForCity(cityName: string, country?: string): string {
|
||||||
|
const q = cityName.trim().toLowerCase()
|
||||||
|
if (!q) return ''
|
||||||
|
const code = country ? countryCodeFromName(country) : countryCode
|
||||||
|
const match = locations.find((l) => {
|
||||||
|
if (l.name.trim().toLowerCase() !== q) return false
|
||||||
|
if (code && l.country && l.country.toUpperCase() !== code.toUpperCase()) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return match?.id ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function cityNameForLocationId(locationId: string): string {
|
||||||
|
return locations.find((l) => l.id === locationId)?.name ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function countryNameForLocationId(locationId: string): string {
|
||||||
|
const code = locations.find((l) => l.id === locationId)?.country
|
||||||
|
return countryNameFromCode(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshPreview(
|
||||||
|
overrides?: Partial<{
|
||||||
|
zoneId: string
|
||||||
|
locationId: string
|
||||||
|
role: NodeRole
|
||||||
|
indexNum: number
|
||||||
|
providerTag: string
|
||||||
|
ipv4: string
|
||||||
|
ipv6: string
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
const values = { ...form.getValues(), ...overrides }
|
||||||
|
if (!values.zoneId || !values.locationId || !values.role) {
|
||||||
|
setPreviewHost('')
|
||||||
|
setPreviewRecords([])
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const res = await previewHostname({
|
const res = await previewHostname({
|
||||||
zoneId: watchZone,
|
zoneId: values.zoneId,
|
||||||
locationId: watchLoc,
|
locationId: values.locationId,
|
||||||
role: watchRole,
|
role: values.role,
|
||||||
indexNum: Number(watchIndex) || 1,
|
indexNum: Number(values.indexNum) || 1,
|
||||||
providerTag: watchProvider || undefined,
|
providerTag: values.providerTag || undefined,
|
||||||
|
ipv4: values.ipv4 || undefined,
|
||||||
|
ipv6: values.ipv6 || undefined,
|
||||||
|
nodeId: editing?.id,
|
||||||
})
|
})
|
||||||
setPreview(res.hostname)
|
setPreviewHost(res.hostname)
|
||||||
|
setPreviewRecords(res.records ?? [])
|
||||||
} catch {
|
} catch {
|
||||||
setPreview('')
|
setPreviewHost('')
|
||||||
|
setPreviewRecords([])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sheetOpen) return
|
||||||
|
void refreshPreview()
|
||||||
|
// form + editing captured via refreshPreview closures; watches drive re-run
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentional field watches
|
||||||
|
}, [
|
||||||
|
sheetOpen,
|
||||||
|
watchZone,
|
||||||
|
watchLoc,
|
||||||
|
watchRole,
|
||||||
|
watchIndex,
|
||||||
|
watchProvider,
|
||||||
|
watchIpv4,
|
||||||
|
watchIpv6,
|
||||||
|
editing?.id,
|
||||||
|
])
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: async (values: FormValues) => {
|
mutationFn: async (values: FormValues) => {
|
||||||
if (editing) {
|
if (editing) {
|
||||||
@@ -148,6 +275,8 @@ function NodesPage() {
|
|||||||
toast.success(editing ? 'Нода обновлена' : 'Нода создана')
|
toast.success(editing ? 'Нода обновлена' : 'Нода создана')
|
||||||
setSheetOpen(false)
|
setSheetOpen(false)
|
||||||
setEditing(null)
|
setEditing(null)
|
||||||
|
setCountryName('')
|
||||||
|
setLocationQuery('')
|
||||||
form.reset()
|
form.reset()
|
||||||
void qc.invalidateQueries({ queryKey: ['fleet'] })
|
void qc.invalidateQueries({ queryKey: ['fleet'] })
|
||||||
},
|
},
|
||||||
@@ -176,7 +305,10 @@ function NodesPage() {
|
|||||||
key: 'locationCode',
|
key: 'locationCode',
|
||||||
label: 'Локация',
|
label: 'Локация',
|
||||||
type: 'select',
|
type: 'select',
|
||||||
options: locations.map((l) => ({ value: l.code, label: l.code })),
|
options: locations.map((l) => ({
|
||||||
|
value: l.code,
|
||||||
|
label: l.name,
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'role',
|
key: 'role',
|
||||||
@@ -214,16 +346,35 @@ function NodesPage() {
|
|||||||
{
|
{
|
||||||
accessorKey: 'locationCode',
|
accessorKey: 'locationCode',
|
||||||
header: 'Локация',
|
header: 'Локация',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
<span className="flex items-center gap-1.5 text-sm">
|
const n = row.original
|
||||||
<MapPinIcon className="size-3.5" />
|
const loc = locations.find((l) => l.id === n.locationId)
|
||||||
{row.original.locationCode ?? '—'}
|
const city = n.locationName ?? loc?.name ?? n.locationCode ?? '—'
|
||||||
</span>
|
const countryCode = loc?.country ?? undefined
|
||||||
),
|
return (
|
||||||
|
<span className="flex items-center gap-2 text-sm">
|
||||||
|
<CountryFlag code={countryCode ?? undefined} country={countryNameFromCode(countryCode)} />
|
||||||
|
<span className="font-medium">{city}</span>
|
||||||
|
{n.locationCode ? (
|
||||||
|
<span className="text-muted-foreground font-mono text-xs">
|
||||||
|
{n.locationCode}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'role',
|
accessorKey: 'role',
|
||||||
header: 'Роль',
|
header: 'Роль',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const role = row.original.role as NodeRole
|
||||||
|
return (
|
||||||
|
<span className="text-sm">
|
||||||
|
{ROLE_LABEL[role] ?? row.original.role}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'ip',
|
id: 'ip',
|
||||||
@@ -267,6 +418,8 @@ function NodesPage() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
const n = row.original
|
const n = row.original
|
||||||
setEditing(n)
|
setEditing(n)
|
||||||
|
setCountryName(countryNameForLocationId(n.locationId))
|
||||||
|
setLocationQuery(cityNameForLocationId(n.locationId))
|
||||||
form.reset({
|
form.reset({
|
||||||
zoneId: n.zoneId,
|
zoneId: n.zoneId,
|
||||||
locationId: n.locationId,
|
locationId: n.locationId,
|
||||||
@@ -278,7 +431,8 @@ function NodesPage() {
|
|||||||
notes: n.notes ?? '',
|
notes: n.notes ?? '',
|
||||||
hostname: n.hostname,
|
hostname: n.hostname,
|
||||||
})
|
})
|
||||||
setPreview(n.hostname)
|
setPreviewHost(n.hostname)
|
||||||
|
setPreviewRecords([])
|
||||||
setSheetOpen(true)
|
setSheetOpen(true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -306,9 +460,11 @@ function NodesPage() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditing(null)
|
setEditing(null)
|
||||||
|
setCountryName('')
|
||||||
|
setLocationQuery('')
|
||||||
form.reset({
|
form.reset({
|
||||||
zoneId: zones[0]?.id ?? '',
|
zoneId: zones[0]?.id ?? '',
|
||||||
locationId: locations[0]?.id ?? '',
|
locationId: '',
|
||||||
role: 'gw',
|
role: 'gw',
|
||||||
indexNum: 1,
|
indexNum: 1,
|
||||||
ipv4: '',
|
ipv4: '',
|
||||||
@@ -317,9 +473,9 @@ function NodesPage() {
|
|||||||
notes: '',
|
notes: '',
|
||||||
hostname: '',
|
hostname: '',
|
||||||
})
|
})
|
||||||
setPreview('')
|
setPreviewHost('')
|
||||||
|
setPreviewRecords([])
|
||||||
setSheetOpen(true)
|
setSheetOpen(true)
|
||||||
void refreshPreview()
|
|
||||||
}}
|
}}
|
||||||
disabled={zones.length === 0}
|
disabled={zones.length === 0}
|
||||||
>
|
>
|
||||||
@@ -387,8 +543,7 @@ function NodesPage() {
|
|||||||
<SelectField
|
<SelectField
|
||||||
value={form.watch('zoneId')}
|
value={form.watch('zoneId')}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
form.setValue('zoneId', v ?? '')
|
form.setValue('zoneId', v ?? '', { shouldDirty: true })
|
||||||
void refreshPreview()
|
|
||||||
}}
|
}}
|
||||||
placeholder="Зона"
|
placeholder="Зона"
|
||||||
options={zones.map((z) => ({ value: z.id, label: z.name }))}
|
options={zones.map((z) => ({ value: z.id, label: z.name }))}
|
||||||
@@ -396,21 +551,55 @@ function NodesPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<FormFieldSimple label="Страна" htmlFor="node-country">
|
||||||
<Label>Локация</Label>
|
<AutoCompleteInput
|
||||||
<SelectField
|
id="node-country"
|
||||||
value={form.watch('locationId')}
|
placeholder="Любая"
|
||||||
onValueChange={(v) => {
|
value={countryName}
|
||||||
form.setValue('locationId', v ?? '')
|
onChange={(v) => {
|
||||||
void refreshPreview()
|
setCountryName(v)
|
||||||
|
if (
|
||||||
|
v.trim() &&
|
||||||
|
locationQuery.trim() &&
|
||||||
|
!cityMatchesCountry(locationQuery, v, locationRowsForGeo)
|
||||||
|
) {
|
||||||
|
setLocationQuery('')
|
||||||
|
form.setValue('locationId', '')
|
||||||
|
setPreviewHost('')
|
||||||
|
setPreviewRecords([])
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="Локация"
|
options={countryOptions}
|
||||||
options={locations.map((l) => ({
|
searchPlaceholder="Поиск страны…"
|
||||||
value: l.id,
|
emptyText="Нет вариантов"
|
||||||
label: `${l.code} — ${l.name}`,
|
|
||||||
}))}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</FormFieldSimple>
|
||||||
|
<FormFieldSimple label="Город" htmlFor="node-city">
|
||||||
|
<AutoCompleteInput
|
||||||
|
id="node-city"
|
||||||
|
placeholder="Любой"
|
||||||
|
value={locationQuery}
|
||||||
|
onChange={(v) => {
|
||||||
|
setLocationQuery(v)
|
||||||
|
const resolvedCountry =
|
||||||
|
resolveCountryForCityFromRows(v, locationRowsForGeo) ?? countryName
|
||||||
|
if (resolvedCountry && resolvedCountry !== countryName) {
|
||||||
|
setCountryName(resolvedCountry)
|
||||||
|
}
|
||||||
|
const id = locationIdForCity(v, resolvedCountry || countryName)
|
||||||
|
form.setValue('locationId', id)
|
||||||
|
if (id) void refreshPreview({ locationId: id })
|
||||||
|
else {
|
||||||
|
setPreviewHost('')
|
||||||
|
setPreviewRecords([])
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
options={cityOptions}
|
||||||
|
searchPlaceholder="Поиск города…"
|
||||||
|
emptyText="Нет вариантов"
|
||||||
|
showLeadingInInput={false}
|
||||||
|
/>
|
||||||
|
</FormFieldSimple>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
@@ -418,8 +607,9 @@ function NodesPage() {
|
|||||||
<SelectField
|
<SelectField
|
||||||
value={form.watch('role')}
|
value={form.watch('role')}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
form.setValue('role', (v as NodeRole) ?? 'gw')
|
const role = (v as NodeRole) ?? 'gw'
|
||||||
void refreshPreview()
|
form.setValue('role', role)
|
||||||
|
void refreshPreview({ role })
|
||||||
}}
|
}}
|
||||||
options={ROLES.map((r) => ({ value: r.value, label: r.label }))}
|
options={ROLES.map((r) => ({ value: r.value, label: r.label }))}
|
||||||
/>
|
/>
|
||||||
@@ -430,27 +620,17 @@ function NodesPage() {
|
|||||||
type="number"
|
type="number"
|
||||||
min={1}
|
min={1}
|
||||||
max={99}
|
max={99}
|
||||||
{...form.register('indexNum', { valueAsNumber: true })}
|
{...form.register('indexNum', {
|
||||||
onBlur={() => void refreshPreview()}
|
valueAsNumber: true,
|
||||||
|
onChange: (e) => {
|
||||||
|
const n = Number(e.target.value) || 1
|
||||||
|
void refreshPreview({ indexNum: n })
|
||||||
|
},
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-muted/40 flex items-center gap-2 rounded-lg border px-3 py-2 text-sm">
|
|
||||||
<CloudIcon className="size-4 shrink-0" />
|
|
||||||
<span className="text-muted-foreground">Preview:</span>
|
|
||||||
<code className="font-medium">{preview || '—'}</code>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="ml-auto"
|
|
||||||
onClick={() => void refreshPreview()}
|
|
||||||
>
|
|
||||||
<RefreshCwIcon className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>IPv4</Label>
|
<Label>IPv4</Label>
|
||||||
<Input {...form.register('ipv4')} placeholder="198.51.100.10" />
|
<Input {...form.register('ipv4')} placeholder="198.51.100.10" />
|
||||||
@@ -467,6 +647,48 @@ function NodesPage() {
|
|||||||
<Label>Заметки</Label>
|
<Label>Заметки</Label>
|
||||||
<Input {...form.register('notes')} />
|
<Input {...form.register('notes')} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-muted/40 flex flex-col gap-2 rounded-lg border px-3 py-2 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CloudIcon className="size-4 shrink-0" />
|
||||||
|
<span className="text-muted-foreground">Preview FQDN:</span>
|
||||||
|
<code className="font-medium">{previewHost || '—'}</code>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="ml-auto"
|
||||||
|
onClick={() => void refreshPreview()}
|
||||||
|
>
|
||||||
|
<RefreshCwIcon className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{previewRecords.length > 0 ? (
|
||||||
|
<ul className="flex flex-col gap-1 font-mono text-xs tabular-nums">
|
||||||
|
{previewRecords.map((r) => (
|
||||||
|
<li
|
||||||
|
key={`${r.type}:${r.name}:${r.content}`}
|
||||||
|
className="text-muted-foreground flex flex-wrap items-baseline gap-x-2"
|
||||||
|
>
|
||||||
|
<span className="text-foreground w-12 shrink-0 font-semibold">
|
||||||
|
{r.type}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 break-all">
|
||||||
|
{r.name} → {r.content}
|
||||||
|
</span>
|
||||||
|
{r.note ? (
|
||||||
|
<span className="opacity-70">({r.note})</span>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : previewHost ? (
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
Укажите IPv4/IPv6 — появятся A/AAAA. При редактировании — CNAME
|
||||||
|
алиасов на эту ноду.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</FormSheet>
|
</FormSheet>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import {
|
||||||
|
authPortalUrl,
|
||||||
|
clearToken,
|
||||||
|
ensureAuthConfig,
|
||||||
|
redirectToPortalLogout,
|
||||||
|
} from '@/lib/auth'
|
||||||
|
import { Button } from '@cdnmanager/ui/components/button'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/access-denied')({
|
||||||
|
beforeLoad: async () => {
|
||||||
|
await ensureAuthConfig()
|
||||||
|
},
|
||||||
|
component: AccessDeniedPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function AccessDeniedPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-svh flex-col items-center justify-center gap-4 p-6 text-center">
|
||||||
|
<h1 className="text-lg font-semibold">Нет доступа к CDN Manager</h1>
|
||||||
|
<p className="text-muted-foreground max-w-md text-sm">
|
||||||
|
В JWT нет приложения <code className="text-xs">cdn</code> или нужных прав{' '}
|
||||||
|
<code className="text-xs">cdn:*</code>. Выдайте доступ в Auth Portal →
|
||||||
|
Админка → пользователи, затем войдите снова.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
render={<a href={`${authPortalUrl().replace(/\/$/, '')}/admin`} />}
|
||||||
|
>
|
||||||
|
Открыть портал
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="default"
|
||||||
|
onClick={() => {
|
||||||
|
clearToken()
|
||||||
|
redirectToPortalLogout()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Выйти
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||||
import {
|
import {
|
||||||
authPortalUrl,
|
|
||||||
clearPortalHandoffFlag,
|
clearPortalHandoffFlag,
|
||||||
clearToken,
|
clearToken,
|
||||||
ensureAuthConfig,
|
ensureAuthConfig,
|
||||||
@@ -9,9 +8,22 @@ import {
|
|||||||
getToken,
|
getToken,
|
||||||
parseHashToken,
|
parseHashToken,
|
||||||
redirectToPortalLogin,
|
redirectToPortalLogin,
|
||||||
|
redirectToPortalLoginInteractive,
|
||||||
setToken,
|
setToken,
|
||||||
} from '@/lib/auth'
|
} from '@/lib/auth'
|
||||||
|
|
||||||
|
async function verifyTokenAccepted(token: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/v1/locations', {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
// 401 = JWT rejected (secret/issuer). 403 = JWT ok, RBAC — still accepted.
|
||||||
|
return res.status !== 401
|
||||||
|
} catch {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const Route = createFileRoute('/auth/callback')({
|
export const Route = createFileRoute('/auth/callback')({
|
||||||
validateSearch: (search: Record<string, unknown>) => ({
|
validateSearch: (search: Record<string, unknown>) => ({
|
||||||
error: typeof search.error === 'string' ? search.error : undefined,
|
error: typeof search.error === 'string' ? search.error : undefined,
|
||||||
@@ -19,30 +31,56 @@ export const Route = createFileRoute('/auth/callback')({
|
|||||||
beforeLoad: async ({ search }) => {
|
beforeLoad: async ({ search }) => {
|
||||||
await ensureAuthConfig()
|
await ensureAuthConfig()
|
||||||
|
|
||||||
if (search.error === 'sso_loop') {
|
// Dead-end errors → interactive portal login (no return_to storm).
|
||||||
|
if (search.error === 'sso_loop' || search.error === 'jwt_rejected') {
|
||||||
|
redirectToPortalLoginInteractive()
|
||||||
|
await new Promise(() => {})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const { accessToken } = parseHashToken(window.location.hash)
|
const { accessToken } = parseHashToken(window.location.hash)
|
||||||
if (accessToken) {
|
if (accessToken) {
|
||||||
setToken(accessToken)
|
setToken(accessToken)
|
||||||
|
// Match CFDM/VPS: clear handoff flag only — do not start a new cooldown
|
||||||
|
// after a successful SSO (that caused false sso_loop on expiry re-login).
|
||||||
clearPortalHandoffFlag()
|
clearPortalHandoffFlag()
|
||||||
|
|
||||||
const claims = getClaims()
|
const claims = getClaims()
|
||||||
if (!claims) {
|
if (!claims) {
|
||||||
clearToken()
|
clearToken()
|
||||||
window.location.assign(authPortalUrl())
|
redirectToPortalLoginInteractive()
|
||||||
await new Promise(() => {})
|
await new Promise(() => {})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
throw redirect({ to: firstAllowedPath() as '/' })
|
if (!claims.apps.includes('cdn')) {
|
||||||
|
throw redirect({ to: '/access-denied' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await verifyTokenAccepted(accessToken)
|
||||||
|
if (!ok) {
|
||||||
|
clearToken()
|
||||||
|
redirectToPortalLoginInteractive()
|
||||||
|
await new Promise(() => {})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = firstAllowedPath()
|
||||||
|
if (next === '/access-denied') {
|
||||||
|
throw redirect({ to: '/access-denied' })
|
||||||
|
}
|
||||||
|
throw redirect({ to: next as '/' })
|
||||||
}
|
}
|
||||||
if (getToken() && getClaims()) {
|
if (getToken() && getClaims()) {
|
||||||
clearPortalHandoffFlag()
|
clearPortalHandoffFlag()
|
||||||
|
if (!getClaims()!.apps.includes('cdn')) {
|
||||||
|
throw redirect({ to: '/access-denied' })
|
||||||
|
}
|
||||||
throw redirect({ to: firstAllowedPath() as '/' })
|
throw redirect({ to: firstAllowedPath() as '/' })
|
||||||
}
|
}
|
||||||
const ok = redirectToPortalLogin(`${window.location.origin}/auth/callback`)
|
const ok = redirectToPortalLogin(`${window.location.origin}/auth/callback`)
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
throw redirect({ to: '/auth/callback', search: { error: 'sso_loop' } })
|
// Cooldown: fall back to interactive portal login instead of sso_loop page.
|
||||||
|
redirectToPortalLoginInteractive()
|
||||||
}
|
}
|
||||||
await new Promise(() => {})
|
await new Promise(() => {})
|
||||||
},
|
},
|
||||||
@@ -50,21 +88,10 @@ export const Route = createFileRoute('/auth/callback')({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function AuthCallbackPage() {
|
function AuthCallbackPage() {
|
||||||
const { error } = Route.useSearch()
|
// beforeLoad always navigates away; placeholder while assigning location.
|
||||||
if (error === 'sso_loop') {
|
return (
|
||||||
return (
|
<div className="text-muted-foreground flex min-h-svh items-center justify-center p-6 text-sm">
|
||||||
<div className="flex min-h-svh flex-col items-center justify-center gap-3 p-6 text-center">
|
Перенаправление на Auth Portal…
|
||||||
<h1 className="text-lg font-semibold">Сессия не принята</h1>
|
</div>
|
||||||
<p className="text-muted-foreground max-w-md text-sm">
|
)
|
||||||
Повторный вход через portal остановлен (защита от цикла редиректов).
|
|
||||||
Обычно это несовпадение JWT_SECRET / ISSUER или просроченный токен.
|
|
||||||
Войдите заново на portal, затем откройте CDN Manager.
|
|
||||||
</p>
|
|
||||||
<a className="text-primary text-sm underline" href={authPortalUrl()}>
|
|
||||||
Открыть Auth Portal
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
# CDN Manager + MikrotikManager + one Traefik (production).
|
||||||
|
#
|
||||||
|
# Hosts:
|
||||||
|
# https://cdn.shnt.top → cdnmanager:8080
|
||||||
|
# https://mm.shnt.top → mmapp-frontend:3000 → backend:8000 (internal rewrite)
|
||||||
|
#
|
||||||
|
# On server:
|
||||||
|
# mkdir -p /opt/cdn-mm/{data/cdn,data/mm,state,updater}
|
||||||
|
# cp deploy/docker-compose.cdn-mm.yml /opt/cdn-mm/docker-compose.yml
|
||||||
|
# cp deploy/env.cdn-mm.example /opt/cdn-mm/.env # fill secrets
|
||||||
|
# # targets.json:
|
||||||
|
# # cp deploy/updater/targets.json.example /opt/cdn-mm/updater/targets.json
|
||||||
|
# # (в CDNManager-репо скачайте тот же файл из MikrotikManager)
|
||||||
|
# docker login git.shx.one
|
||||||
|
# cd /opt/cdn-mm && docker compose pull && docker compose up -d
|
||||||
|
#
|
||||||
|
# DNS (Cloudflare DNS only, grey cloud):
|
||||||
|
# A/AAAA cdn.shnt.top → VPS
|
||||||
|
# A/AAAA mm.shnt.top → VPS
|
||||||
|
#
|
||||||
|
# Do not run a second Traefik (standalone CDNManager or MikrotikManager compose)
|
||||||
|
# on the same host ports while this stack is up.
|
||||||
|
|
||||||
|
services:
|
||||||
|
traefik:
|
||||||
|
image: traefik:${TRAEFIK_IMAGE_TAG:-v3.7}
|
||||||
|
container_name: cdn-mm-traefik
|
||||||
|
restart: unless-stopped
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
ports:
|
||||||
|
- "${TRAEFIK_HTTP_PORT:-80}:80"
|
||||||
|
- "${TRAEFIK_HTTPS_PORT:-443}:443"
|
||||||
|
environment:
|
||||||
|
CF_DNS_API_TOKEN: ${CF_DNS_API_TOKEN:?set CF_DNS_API_TOKEN in .env}
|
||||||
|
# Optional if DNS token lacks Zone:Read:
|
||||||
|
# CF_ZONE_API_TOKEN: ${CF_ZONE_API_TOKEN:-}
|
||||||
|
command:
|
||||||
|
- --log.level=${TRAEFIK_LOG_LEVEL:-INFO}
|
||||||
|
- --api.dashboard=false
|
||||||
|
- --providers.docker=true
|
||||||
|
- --providers.docker.exposedbydefault=false
|
||||||
|
- --providers.docker.network=edge
|
||||||
|
- --entrypoints.web.address=:80
|
||||||
|
- --entrypoints.websecure.address=:443
|
||||||
|
- --entrypoints.web.http.redirections.entrypoint.to=websecure
|
||||||
|
- --entrypoints.web.http.redirections.entrypoint.scheme=https
|
||||||
|
- --certificatesresolvers.letsencrypt.acme.email=${LETSENCRYPT_EMAIL:?set LETSENCRYPT_EMAIL in .env}
|
||||||
|
- --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
|
||||||
|
- --certificatesresolvers.letsencrypt.acme.dnschallenge=true
|
||||||
|
- --certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare
|
||||||
|
- --certificatesresolvers.letsencrypt.acme.dnschallenge.delaybeforecheck=15
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
- traefik_letsencrypt:/letsencrypt
|
||||||
|
networks:
|
||||||
|
- edge
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
# --- CDN Manager -----------------------------------------------------------
|
||||||
|
cdnmanager:
|
||||||
|
# cdnmanager и cdn-manager — один образ (алиас для drop-in).
|
||||||
|
image: git.shx.one/denozord/cdnmanager:${CDN_IMAGE_TAG:-latest}
|
||||||
|
pull_policy: always
|
||||||
|
container_name: cdnmanager
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- traefik
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: sqlite:/data/app.db
|
||||||
|
STATIC_DIR: /app/static
|
||||||
|
SERVER_PORT: "8080"
|
||||||
|
NODE_ENV: production
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
|
CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN:?set CLOUDFLARE_API_TOKEN in .env}
|
||||||
|
JWT_SECRET: ${JWT_SECRET:-}
|
||||||
|
AUTH_REQUIRED: ${AUTH_REQUIRED:-true}
|
||||||
|
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET in .env}
|
||||||
|
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
|
||||||
|
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-https://auth.shnt.top}
|
||||||
|
AUTH_AUDIT_INGEST_SECRET: ${AUTH_AUDIT_INGEST_SECRET:-}
|
||||||
|
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||||
|
ADMIN_PASSWORD_HASH: ${ADMIN_PASSWORD_HASH:-}
|
||||||
|
volumes:
|
||||||
|
- ./data/cdn:/data
|
||||||
|
networks:
|
||||||
|
- edge
|
||||||
|
labels:
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.docker.network=edge
|
||||||
|
- traefik.http.routers.cdnmanager.rule=Host(`${CDN_DOMAIN:-cdn.shnt.top}`)
|
||||||
|
- traefik.http.routers.cdnmanager.entrypoints=websecure
|
||||||
|
- traefik.http.routers.cdnmanager.tls=true
|
||||||
|
- traefik.http.routers.cdnmanager.tls.certresolver=letsencrypt
|
||||||
|
- traefik.http.services.cdnmanager.loadbalancer.server.port=8080
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"node",
|
||||||
|
"-e",
|
||||||
|
"fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||||
|
]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
# --- MikrotikManager -------------------------------------------------------
|
||||||
|
backend:
|
||||||
|
image: git.shx.one/denozord/mikrotikmanager-backend:${MM_BACKEND_IMAGE_TAG:-latest}
|
||||||
|
pull_policy: always
|
||||||
|
container_name: mmapp-backend
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- traefik
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: "8000"
|
||||||
|
DATABASE_PATH: /app/data/mikrotik.db
|
||||||
|
CORS_ORIGIN: ${CORS_ORIGIN:-https://mm.shnt.top}
|
||||||
|
AUTH_REQUIRED: ${AUTH_REQUIRED:-true}
|
||||||
|
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET in .env}
|
||||||
|
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
|
||||||
|
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-https://auth.shnt.top}
|
||||||
|
volumes:
|
||||||
|
- ./data/mm:/app/data
|
||||||
|
networks:
|
||||||
|
mmapp:
|
||||||
|
aliases:
|
||||||
|
- backend
|
||||||
|
labels:
|
||||||
|
mmapp.updater.managed: "true"
|
||||||
|
mmapp.updater.target: backend
|
||||||
|
mmapp.updater.image: git.shx.one/denozord/mikrotikmanager-backend:${MM_BACKEND_IMAGE_TAG:-latest}
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"node",
|
||||||
|
"-e",
|
||||||
|
"fetch('http://127.0.0.1:8000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||||
|
]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 15s
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: git.shx.one/denozord/mikrotikmanager-frontend:${MM_FRONTEND_IMAGE_TAG:-latest}
|
||||||
|
pull_policy: always
|
||||||
|
container_name: mmapp-frontend
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
environment:
|
||||||
|
BACKEND_INTERNAL_URL: http://backend:8000
|
||||||
|
networks:
|
||||||
|
mmapp:
|
||||||
|
aliases:
|
||||||
|
- frontend
|
||||||
|
edge: {}
|
||||||
|
labels:
|
||||||
|
- mmapp.updater.managed=true
|
||||||
|
- mmapp.updater.target=frontend
|
||||||
|
- mmapp.updater.image=git.shx.one/denozord/mikrotikmanager-frontend:${MM_FRONTEND_IMAGE_TAG:-latest}
|
||||||
|
- traefik.enable=true
|
||||||
|
- traefik.docker.network=edge
|
||||||
|
- traefik.http.routers.mmapp.rule=Host(`${MM_DOMAIN:-mm.shnt.top}`)
|
||||||
|
- traefik.http.routers.mmapp.entrypoints=websecure
|
||||||
|
- traefik.http.routers.mmapp.tls=true
|
||||||
|
- traefik.http.routers.mmapp.tls.certresolver=letsencrypt
|
||||||
|
- traefik.http.services.mmapp.loadbalancer.server.port=3000
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"node",
|
||||||
|
"-e",
|
||||||
|
"fetch('http://127.0.0.1:3000/dashboard').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||||
|
]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 25s
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
updater:
|
||||||
|
image: git.shx.one/denozord/mikrotikmanager-updater:${MM_UPDATER_IMAGE_TAG:-latest}
|
||||||
|
pull_policy: always
|
||||||
|
container_name: mmapp-updater
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- frontend
|
||||||
|
environment:
|
||||||
|
REGISTRY: git.shx.one
|
||||||
|
REGISTRY_USERNAME: ${REGISTRY_USERNAME:-}
|
||||||
|
REGISTRY_PASSWORD: ${REGISTRY_PASSWORD:-}
|
||||||
|
POLL_INTERVAL_SECONDS: ${POLL_INTERVAL_SECONDS:-300}
|
||||||
|
HEALTH_TIMEOUT_SECONDS: ${HEALTH_TIMEOUT_SECONDS:-120}
|
||||||
|
STOP_TIMEOUT_SECONDS: ${STOP_TIMEOUT_SECONDS:-30}
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
- ./state:/state
|
||||||
|
- ./updater/targets.json:/etc/updater/targets.json:ro
|
||||||
|
networks:
|
||||||
|
- mmapp
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
traefik_letsencrypt:
|
||||||
|
name: cdn_mm_traefik_letsencrypt
|
||||||
|
|
||||||
|
networks:
|
||||||
|
edge:
|
||||||
|
name: edge
|
||||||
|
mmapp:
|
||||||
|
name: mmapp
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Production .env for deploy/docker-compose.cdn-mm.yml
|
||||||
|
# (CDN Manager + MikrotikManager + one Traefik).
|
||||||
|
# Copy to /opt/cdn-mm/.env and fill secrets. Do not commit.
|
||||||
|
|
||||||
|
# --- Traefik / Let's Encrypt (Cloudflare DNS-01) ---
|
||||||
|
# Token for ACME only (Zone DNS Edit). Separate from CLOUDFLARE_API_TOKEN below.
|
||||||
|
CF_DNS_API_TOKEN=
|
||||||
|
LETSENCRYPT_EMAIL=admin@shnt.top
|
||||||
|
# TRAEFIK_IMAGE_TAG=v3.7
|
||||||
|
# TRAEFIK_HTTP_PORT=80
|
||||||
|
# TRAEFIK_HTTPS_PORT=443
|
||||||
|
# TRAEFIK_LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# --- Public hosts ---
|
||||||
|
CDN_DOMAIN=cdn.shnt.top
|
||||||
|
MM_DOMAIN=mm.shnt.top
|
||||||
|
# Must match MM UI origin (https:// + MM_DOMAIN).
|
||||||
|
CORS_ORIGIN=https://mm.shnt.top
|
||||||
|
|
||||||
|
# --- Images ---
|
||||||
|
CDN_IMAGE_TAG=latest
|
||||||
|
# drop-in alias (same manifest): git.shx.one/denozord/cdn-manager
|
||||||
|
MM_BACKEND_IMAGE_TAG=latest
|
||||||
|
MM_FRONTEND_IMAGE_TAG=latest
|
||||||
|
MM_UPDATER_IMAGE_TAG=latest
|
||||||
|
|
||||||
|
# --- CDN Manager ---
|
||||||
|
CLOUDFLARE_API_TOKEN=
|
||||||
|
LOG_LEVEL=info
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
|
# Portal SSO — used by CDN Manager and MikrotikManager backend
|
||||||
|
AUTH_REQUIRED=true
|
||||||
|
# Same HS256 secret as auth-portal JWT_SECRET (required)
|
||||||
|
AUTH_JWT_SECRET=
|
||||||
|
# Optional alias — CDN Manager also reads JWT_SECRET
|
||||||
|
JWT_SECRET=
|
||||||
|
AUTH_ISSUER=https://auth.shnt.top
|
||||||
|
AUTH_PORTAL_URL=https://auth.shnt.top
|
||||||
|
# Shared with auth-portal AUDIT_INGEST_SECRET (optional, CDN Manager)
|
||||||
|
AUTH_AUDIT_INGEST_SECRET=
|
||||||
|
|
||||||
|
# Legacy local admin (CDN) — only when AUTH_REQUIRED=false
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD_HASH=
|
||||||
|
|
||||||
|
# --- MikrotikManager updater (optional; private registry pull) ---
|
||||||
|
REGISTRY_USERNAME=
|
||||||
|
REGISTRY_PASSWORD=
|
||||||
|
# POLL_INTERVAL_SECONDS=300
|
||||||
|
# HEALTH_TIMEOUT_SECONDS=120
|
||||||
|
# STOP_TIMEOUT_SECONDS=30
|
||||||
@@ -9,6 +9,7 @@ Self-hosted панель управления DNS флота (ноды A/AAAA +
|
|||||||
- Не замена CFDM: там домены/сервисы/LB; здесь флот CHR и failover CNAME
|
- Не замена CFDM: там домены/сервисы/LB; здесь флот CHR и failover CNAME
|
||||||
- SSO: [`integrate-auth-portal.md`](./integrate-auth-portal.md)
|
- SSO: [`integrate-auth-portal.md`](./integrate-auth-portal.md)
|
||||||
- Docker + Traefik (prod): [`deploy-traefik.md`](./deploy-traefik.md)
|
- Docker + Traefik (prod): [`deploy-traefik.md`](./deploy-traefik.md)
|
||||||
|
- Docker CDN + MikrotikManager (один Traefik): [`../deploy/docker-compose.cdn-mm.yml`](../deploy/docker-compose.cdn-mm.yml)
|
||||||
- Docker без Traefik: [`deploy-docker.md`](./deploy-docker.md)
|
- Docker без Traefik: [`deploy-docker.md`](./deploy-docker.md)
|
||||||
|
|
||||||
См. [README](../README.md) и [AGENTS.md](../AGENTS.md).
|
См. [README](../README.md) и [AGENTS.md](../AGENTS.md).
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
| SSO | [`integrate-auth-portal.md`](./integrate-auth-portal.md) |
|
| SSO | [`integrate-auth-portal.md`](./integrate-auth-portal.md) |
|
||||||
| Релизы | [`releasing.md`](./releasing.md) |
|
| Релизы | [`releasing.md`](./releasing.md) |
|
||||||
|
|
||||||
|
**CDN + MikrotikManager на одном Traefik:** [`deploy/docker-compose.cdn-mm.yml`](../deploy/docker-compose.cdn-mm.yml) + [`deploy/env.cdn-mm.example`](../deploy/env.cdn-mm.example) — `cdn.shnt.top` и `mm.shnt.top`, каталог `/opt/cdn-mm`.
|
||||||
|
|
||||||
Документация Traefik: [Expose Docker](https://doc.traefik.io/traefik/expose/docker/basic/), [ACME DNS challenge](https://doc.traefik.io/traefik/https/acme/).
|
Документация Traefik: [Expose Docker](https://doc.traefik.io/traefik/expose/docker/basic/), [ACME DNS challenge](https://doc.traefik.io/traefik/https/acme/).
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -100,6 +102,8 @@ nano .env # заполнить секреты
|
|||||||
|
|
||||||
На стороне портала добавьте origin в `RETURN_TO_ALLOWLIST` (`https://cdn.shnt.top`) и выдайте app **`cdn`** + права `cdn:*`. App Switcher URL: тот же origin.
|
На стороне портала добавьте origin в `RETURN_TO_ALLOWLIST` (`https://cdn.shnt.top`) и выдайте app **`cdn`** + права `cdn:*`. App Switcher URL: тот же origin.
|
||||||
|
|
||||||
|
**Важно:** `AUTH_JWT_SECRET` в CDN Manager **должен совпадать** с `JWT_SECRET` auth-portal, `AUTH_ISSUER` — с `ISSUER` портала. Иначе после SSO UI зацикливается / «Страница не отвечает».
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Запуск
|
## 3. Запуск
|
||||||
|
|||||||
Vendored
+7
-1
@@ -2863,6 +2863,12 @@ type AliasRow = {
|
|||||||
};
|
};
|
||||||
declare function listLocations(db: Db): LocationRow[];
|
declare function listLocations(db: Db): LocationRow[];
|
||||||
declare function getLocation(db: Db, locationId: string): LocationRow;
|
declare function getLocation(db: Db, locationId: string): LocationRow;
|
||||||
|
/**
|
||||||
|
* Дополняет таблицу locations городами из geo-каталога (тот же, что VPS Tracker).
|
||||||
|
* Существующие коды (msk/fra/…) не перезаписываются.
|
||||||
|
*/
|
||||||
|
declare function ensureCatalogLocations(db: Db): number;
|
||||||
|
declare function findLocationByCity(db: Db, cityName: string, countryCode?: string | null): LocationRow | null;
|
||||||
declare function listZones(db: Db): ZoneRow[];
|
declare function listZones(db: Db): ZoneRow[];
|
||||||
declare function getZone(db: Db, zoneId: string): ZoneRow;
|
declare function getZone(db: Db, zoneId: string): ZoneRow;
|
||||||
declare function createZone(db: Db, input: {
|
declare function createZone(db: Db, input: {
|
||||||
@@ -2987,4 +2993,4 @@ declare function dashboardCounts(db: Db): {
|
|||||||
lastSyncAt: string | null;
|
lastSyncAt: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export { type AliasRow, type AppSettingsDto, type AppSettingsPatch, ConflictError, type Db, type LocationRow, type NodeAddressRow, type NodeRow, NotFoundError, type Sqlite, type ZoneRow, addSyncEvent, aliases, appSettings, createAlias, createDb, createMemoryDb, createNode, createSyncJob, createZone, dashboardCounts, deleteAlias, deleteNode, deleteZone, getAlias, getAppSettings, getLocation, getNode, getSyncJob, getZone, healthCheck, ignoreOrphan, ignoredOrphans, listAliases, listIgnoredOrphans, listLocations, listNodes, listSyncJobs, listZones, locations, nodeAddresses, nodes, resolveDatabasePath, runMigrations, schema, syncEvents, syncJobs, unignoreOrphan, updateAlias, updateAppSettings, updateNode, updateSyncJob, updateZone, zones };
|
export { type AliasRow, type AppSettingsDto, type AppSettingsPatch, ConflictError, type Db, type LocationRow, type NodeAddressRow, type NodeRow, NotFoundError, type Sqlite, type ZoneRow, addSyncEvent, aliases, appSettings, createAlias, createDb, createMemoryDb, createNode, createSyncJob, createZone, dashboardCounts, deleteAlias, deleteNode, deleteZone, ensureCatalogLocations, findLocationByCity, getAlias, getAppSettings, getLocation, getNode, getSyncJob, getZone, healthCheck, ignoreOrphan, ignoredOrphans, listAliases, listIgnoredOrphans, listLocations, listNodes, listSyncJobs, listZones, locations, nodeAddresses, nodes, resolveDatabasePath, runMigrations, schema, syncEvents, syncJobs, unignoreOrphan, updateAlias, updateAppSettings, updateNode, updateSyncJob, updateZone, zones };
|
||||||
|
|||||||
Vendored
+62
@@ -236,6 +236,7 @@ function updateAppSettings(db, patch) {
|
|||||||
// src/fleet-repo.ts
|
// src/fleet-repo.ts
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { and, asc, count, eq as eq2, sql as sql2 } from "drizzle-orm";
|
import { and, asc, count, eq as eq2, sql as sql2 } from "drizzle-orm";
|
||||||
|
import { catalogCitiesWithLocCodes } from "@cdnmanager/shared";
|
||||||
function now() {
|
function now() {
|
||||||
return (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19);
|
return (/* @__PURE__ */ new Date()).toISOString().replace("T", " ").slice(0, 19);
|
||||||
}
|
}
|
||||||
@@ -272,6 +273,65 @@ function getLocation(db, locationId) {
|
|||||||
if (!row) throw new NotFoundError(`location ${locationId}`);
|
if (!row) throw new NotFoundError(`location ${locationId}`);
|
||||||
return mapLocation(row);
|
return mapLocation(row);
|
||||||
}
|
}
|
||||||
|
function ensureCatalogLocations(db) {
|
||||||
|
const existing = db.select().from(locations).all();
|
||||||
|
const byCode = new Map(existing.map((r) => [r.code.toLowerCase(), r]));
|
||||||
|
const byNameCountry = new Map(
|
||||||
|
existing.map((r) => [
|
||||||
|
`${(r.name || "").trim().toLowerCase()}|${(r.country || "").toUpperCase()}`,
|
||||||
|
r
|
||||||
|
])
|
||||||
|
);
|
||||||
|
let inserted = 0;
|
||||||
|
let sortOrder = 100;
|
||||||
|
for (const city of catalogCitiesWithLocCodes()) {
|
||||||
|
const nameKey = `${city.name.trim().toLowerCase()}|${city.countryCode}`;
|
||||||
|
if (byNameCountry.has(nameKey)) continue;
|
||||||
|
if (byCode.has(city.locCode.toLowerCase())) {
|
||||||
|
const alt = `${city.locCode}${city.countryCode.toLowerCase()}`;
|
||||||
|
if (byCode.has(alt) || byNameCountry.has(nameKey)) continue;
|
||||||
|
db.insert(locations).values({
|
||||||
|
id: `loc-${alt}`,
|
||||||
|
code: alt,
|
||||||
|
name: city.name,
|
||||||
|
country: city.countryCode,
|
||||||
|
sort_order: sortOrder++
|
||||||
|
}).run();
|
||||||
|
byCode.set(alt, { id: `loc-${alt}` });
|
||||||
|
byNameCountry.set(nameKey, { id: `loc-${alt}` });
|
||||||
|
inserted += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
db.insert(locations).values({
|
||||||
|
id: `loc-${city.locCode}`,
|
||||||
|
code: city.locCode,
|
||||||
|
name: city.name,
|
||||||
|
country: city.countryCode,
|
||||||
|
sort_order: sortOrder++
|
||||||
|
}).run();
|
||||||
|
byCode.set(city.locCode.toLowerCase(), {
|
||||||
|
id: `loc-${city.locCode}`
|
||||||
|
});
|
||||||
|
byNameCountry.set(nameKey, {
|
||||||
|
id: `loc-${city.locCode}`
|
||||||
|
});
|
||||||
|
inserted += 1;
|
||||||
|
}
|
||||||
|
return inserted;
|
||||||
|
}
|
||||||
|
function findLocationByCity(db, cityName, countryCode) {
|
||||||
|
const q = cityName.trim().toLowerCase();
|
||||||
|
if (!q) return null;
|
||||||
|
const rows = listLocations(db);
|
||||||
|
const match = rows.find((l) => {
|
||||||
|
if (l.name.trim().toLowerCase() !== q) return false;
|
||||||
|
if (countryCode && l.country && l.country.toUpperCase() !== countryCode.toUpperCase()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return match ?? null;
|
||||||
|
}
|
||||||
function listZones(db) {
|
function listZones(db) {
|
||||||
return db.select().from(zones).orderBy(asc(zones.name)).all().map(mapZone);
|
return db.select().from(zones).orderBy(asc(zones.name)).all().map(mapZone);
|
||||||
}
|
}
|
||||||
@@ -645,6 +705,8 @@ export {
|
|||||||
deleteAlias,
|
deleteAlias,
|
||||||
deleteNode,
|
deleteNode,
|
||||||
deleteZone,
|
deleteZone,
|
||||||
|
ensureCatalogLocations,
|
||||||
|
findLocationByCity,
|
||||||
getAlias,
|
getAlias,
|
||||||
getAppSettings,
|
getAppSettings,
|
||||||
getLocation,
|
getLocation,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { and, asc, count, eq, sql } from "drizzle-orm";
|
import { and, asc, count, eq, sql } from "drizzle-orm";
|
||||||
|
import { catalogCitiesWithLocCodes } from "@cdnmanager/shared";
|
||||||
import type { Db } from "./client.js";
|
import type { Db } from "./client.js";
|
||||||
import { NotFoundError, ConflictError } from "./errors.js";
|
import { NotFoundError, ConflictError } from "./errors.js";
|
||||||
import {
|
import {
|
||||||
@@ -126,6 +127,82 @@ export function getLocation(db: Db, locationId: string): LocationRow {
|
|||||||
return mapLocation(row);
|
return mapLocation(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Дополняет таблицу locations городами из geo-каталога (тот же, что VPS Tracker).
|
||||||
|
* Существующие коды (msk/fra/…) не перезаписываются.
|
||||||
|
*/
|
||||||
|
export function ensureCatalogLocations(db: Db): number {
|
||||||
|
const existing = db.select().from(locations).all();
|
||||||
|
const byCode = new Map(existing.map((r) => [r.code.toLowerCase(), r]));
|
||||||
|
const byNameCountry = new Map(
|
||||||
|
existing.map((r) => [
|
||||||
|
`${(r.name || "").trim().toLowerCase()}|${(r.country || "").toUpperCase()}`,
|
||||||
|
r,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
let inserted = 0;
|
||||||
|
let sortOrder = 100;
|
||||||
|
for (const city of catalogCitiesWithLocCodes()) {
|
||||||
|
const nameKey = `${city.name.trim().toLowerCase()}|${city.countryCode}`;
|
||||||
|
if (byNameCountry.has(nameKey)) continue;
|
||||||
|
if (byCode.has(city.locCode.toLowerCase())) {
|
||||||
|
// код занят другой записью — пробуем расширенный код
|
||||||
|
const alt = `${city.locCode}${city.countryCode.toLowerCase()}`;
|
||||||
|
if (byCode.has(alt) || byNameCountry.has(nameKey)) continue;
|
||||||
|
db.insert(locations)
|
||||||
|
.values({
|
||||||
|
id: `loc-${alt}`,
|
||||||
|
code: alt,
|
||||||
|
name: city.name,
|
||||||
|
country: city.countryCode,
|
||||||
|
sort_order: sortOrder++,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
byCode.set(alt, { id: `loc-${alt}` } as (typeof existing)[0]);
|
||||||
|
byNameCountry.set(nameKey, { id: `loc-${alt}` } as (typeof existing)[0]);
|
||||||
|
inserted += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
db.insert(locations)
|
||||||
|
.values({
|
||||||
|
id: `loc-${city.locCode}`,
|
||||||
|
code: city.locCode,
|
||||||
|
name: city.name,
|
||||||
|
country: city.countryCode,
|
||||||
|
sort_order: sortOrder++,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
byCode.set(city.locCode.toLowerCase(), {
|
||||||
|
id: `loc-${city.locCode}`,
|
||||||
|
} as (typeof existing)[0]);
|
||||||
|
byNameCountry.set(nameKey, {
|
||||||
|
id: `loc-${city.locCode}`,
|
||||||
|
} as (typeof existing)[0]);
|
||||||
|
inserted += 1;
|
||||||
|
}
|
||||||
|
return inserted;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findLocationByCity(
|
||||||
|
db: Db,
|
||||||
|
cityName: string,
|
||||||
|
countryCode?: string | null,
|
||||||
|
): LocationRow | null {
|
||||||
|
const q = cityName.trim().toLowerCase();
|
||||||
|
if (!q) return null;
|
||||||
|
const rows = listLocations(db);
|
||||||
|
const match = rows.find((l) => {
|
||||||
|
if (l.name.trim().toLowerCase() !== q) return false;
|
||||||
|
if (countryCode && l.country && l.country.toUpperCase() !== countryCode.toUpperCase()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return match ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export function listZones(db: Db): ZoneRow[] {
|
export function listZones(db: Db): ZoneRow[] {
|
||||||
return db
|
return db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
+467
@@ -0,0 +1,467 @@
|
|||||||
|
// src/geo/countries.ts
|
||||||
|
var COUNTRIES = [
|
||||||
|
{ code: "AU", name: "\u0410\u0432\u0441\u0442\u0440\u0430\u043B\u0438\u044F", nameEn: "Australia" },
|
||||||
|
{ code: "AT", name: "\u0410\u0432\u0441\u0442\u0440\u0438\u044F", nameEn: "Austria" },
|
||||||
|
{ code: "AZ", name: "\u0410\u0437\u0435\u0440\u0431\u0430\u0439\u0434\u0436\u0430\u043D", nameEn: "Azerbaijan" },
|
||||||
|
{ code: "AL", name: "\u0410\u043B\u0431\u0430\u043D\u0438\u044F", nameEn: "Albania" },
|
||||||
|
{ code: "DZ", name: "\u0410\u043B\u0436\u0438\u0440", nameEn: "Algeria" },
|
||||||
|
{ code: "AO", name: "\u0410\u043D\u0433\u043E\u043B\u0430", nameEn: "Angola" },
|
||||||
|
{ code: "AR", name: "\u0410\u0440\u0433\u0435\u043D\u0442\u0438\u043D\u0430", nameEn: "Argentina" },
|
||||||
|
{ code: "AM", name: "\u0410\u0440\u043C\u0435\u043D\u0438\u044F", nameEn: "Armenia" },
|
||||||
|
{ code: "AF", name: "\u0410\u0444\u0433\u0430\u043D\u0438\u0441\u0442\u0430\u043D", nameEn: "Afghanistan" },
|
||||||
|
{ code: "BE", name: "\u0411\u0435\u043B\u044C\u0433\u0438\u044F", nameEn: "Belgium" },
|
||||||
|
{ code: "BG", name: "\u0411\u043E\u043B\u0433\u0430\u0440\u0438\u044F", nameEn: "Bulgaria" },
|
||||||
|
{ code: "BO", name: "\u0411\u043E\u043B\u0438\u0432\u0438\u044F", nameEn: "Bolivia" },
|
||||||
|
{ code: "BA", name: "\u0411\u043E\u0441\u043D\u0438\u044F \u0438 \u0413\u0435\u0440\u0446\u0435\u0433\u043E\u0432\u0438\u043D\u0430", nameEn: "Bosnia and Herzegovina" },
|
||||||
|
{ code: "BR", name: "\u0411\u0440\u0430\u0437\u0438\u043B\u0438\u044F", nameEn: "Brazil" },
|
||||||
|
{ code: "GB", name: "\u0412\u0435\u043B\u0438\u043A\u043E\u0431\u0440\u0438\u0442\u0430\u043D\u0438\u044F", nameEn: "United Kingdom" },
|
||||||
|
{ code: "HU", name: "\u0412\u0435\u043D\u0433\u0440\u0438\u044F", nameEn: "Hungary" },
|
||||||
|
{ code: "VE", name: "\u0412\u0435\u043D\u0435\u0441\u0443\u044D\u043B\u0430", nameEn: "Venezuela" },
|
||||||
|
{ code: "VN", name: "\u0412\u044C\u0435\u0442\u043D\u0430\u043C", nameEn: "Vietnam" },
|
||||||
|
{ code: "GA", name: "\u0413\u0430\u0431\u043E\u043D", nameEn: "Gabon" },
|
||||||
|
{ code: "HT", name: "\u0413\u0430\u0438\u0442\u0438", nameEn: "Haiti" },
|
||||||
|
{ code: "GY", name: "\u0413\u0430\u0439\u0430\u043D\u0430", nameEn: "Guyana" },
|
||||||
|
{ code: "GM", name: "\u0413\u0430\u043C\u0431\u0438\u044F", nameEn: "Gambia" },
|
||||||
|
{ code: "GH", name: "\u0413\u0430\u043D\u0430", nameEn: "Ghana" },
|
||||||
|
{ code: "GT", name: "\u0413\u0432\u0430\u0442\u0435\u043C\u0430\u043B\u0430", nameEn: "Guatemala" },
|
||||||
|
{ code: "GN", name: "\u0413\u0432\u0438\u043D\u0435\u044F", nameEn: "Guinea" },
|
||||||
|
{ code: "DE", name: "\u0413\u0435\u0440\u043C\u0430\u043D\u0438\u044F", nameEn: "Germany" },
|
||||||
|
{ code: "HN", name: "\u0413\u043E\u043D\u0434\u0443\u0440\u0430\u0441", nameEn: "Honduras" },
|
||||||
|
{ code: "GR", name: "\u0413\u0440\u0435\u0446\u0438\u044F", nameEn: "Greece" },
|
||||||
|
{ code: "GE", name: "\u0413\u0440\u0443\u0437\u0438\u044F", nameEn: "Georgia" },
|
||||||
|
{ code: "DK", name: "\u0414\u0430\u043D\u0438\u044F", nameEn: "Denmark" },
|
||||||
|
{ code: "CD", name: "\u0414\u0420 \u041A\u043E\u043D\u0433\u043E", nameEn: "DR Congo" },
|
||||||
|
{ code: "EG", name: "\u0415\u0433\u0438\u043F\u0435\u0442", nameEn: "Egypt" },
|
||||||
|
{ code: "ZM", name: "\u0417\u0430\u043C\u0431\u0438\u044F", nameEn: "Zambia" },
|
||||||
|
{ code: "ZW", name: "\u0417\u0438\u043C\u0431\u0430\u0431\u0432\u0435", nameEn: "Zimbabwe" },
|
||||||
|
{ code: "IL", name: "\u0418\u0437\u0440\u0430\u0438\u043B\u044C", nameEn: "Israel" },
|
||||||
|
{ code: "IN", name: "\u0418\u043D\u0434\u0438\u044F", nameEn: "India" },
|
||||||
|
{ code: "ID", name: "\u0418\u043D\u0434\u043E\u043D\u0435\u0437\u0438\u044F", nameEn: "Indonesia" },
|
||||||
|
{ code: "JO", name: "\u0418\u043E\u0440\u0434\u0430\u043D\u0438\u044F", nameEn: "Jordan" },
|
||||||
|
{ code: "IQ", name: "\u0418\u0440\u0430\u043A", nameEn: "Iraq" },
|
||||||
|
{ code: "IR", name: "\u0418\u0440\u0430\u043D", nameEn: "Iran" },
|
||||||
|
{ code: "IE", name: "\u0418\u0440\u043B\u0430\u043D\u0434\u0438\u044F", nameEn: "Ireland" },
|
||||||
|
{ code: "IS", name: "\u0418\u0441\u043B\u0430\u043D\u0434\u0438\u044F", nameEn: "Iceland" },
|
||||||
|
{ code: "ES", name: "\u0418\u0441\u043F\u0430\u043D\u0438\u044F", nameEn: "Spain" },
|
||||||
|
{ code: "IT", name: "\u0418\u0442\u0430\u043B\u0438\u044F", nameEn: "Italy" },
|
||||||
|
{ code: "YE", name: "\u0419\u0435\u043C\u0435\u043D", nameEn: "Yemen" },
|
||||||
|
{ code: "KZ", name: "\u041A\u0430\u0437\u0430\u0445\u0441\u0442\u0430\u043D", nameEn: "Kazakhstan" },
|
||||||
|
{ code: "KH", name: "\u041A\u0430\u043C\u0431\u043E\u0434\u0436\u0430", nameEn: "Cambodia" },
|
||||||
|
{ code: "CM", name: "\u041A\u0430\u043C\u0435\u0440\u0443\u043D", nameEn: "Cameroon" },
|
||||||
|
{ code: "CA", name: "\u041A\u0430\u043D\u0430\u0434\u0430", nameEn: "Canada" },
|
||||||
|
{ code: "QA", name: "\u041A\u0430\u0442\u0430\u0440", nameEn: "Qatar" },
|
||||||
|
{ code: "KE", name: "\u041A\u0435\u043D\u0438\u044F", nameEn: "Kenya" },
|
||||||
|
{ code: "CY", name: "\u041A\u0438\u043F\u0440", nameEn: "Cyprus" },
|
||||||
|
{ code: "KG", name: "\u041A\u0438\u0440\u0433\u0438\u0437\u0438\u044F", nameEn: "Kyrgyzstan" },
|
||||||
|
{ code: "CN", name: "\u041A\u0438\u0442\u0430\u0439", nameEn: "China" },
|
||||||
|
{ code: "CO", name: "\u041A\u043E\u043B\u0443\u043C\u0431\u0438\u044F", nameEn: "Colombia" },
|
||||||
|
{ code: "CG", name: "\u041A\u043E\u043D\u0433\u043E", nameEn: "Congo" },
|
||||||
|
{ code: "KR", name: "\u042E\u0436\u043D\u0430\u044F \u041A\u043E\u0440\u0435\u044F", nameEn: "South Korea" },
|
||||||
|
{ code: "KP", name: "\u0421\u0435\u0432\u0435\u0440\u043D\u0430\u044F \u041A\u043E\u0440\u0435\u044F", nameEn: "North Korea" },
|
||||||
|
{ code: "XK", name: "\u041A\u043E\u0441\u043E\u0432\u043E", nameEn: "Kosovo" },
|
||||||
|
{ code: "CR", name: "\u041A\u043E\u0441\u0442\u0430-\u0420\u0438\u043A\u0430", nameEn: "Costa Rica" },
|
||||||
|
{ code: "CI", name: "\u041A\u043E\u0442-\u0434'\u0418\u0432\u0443\u0430\u0440", nameEn: "C\xF4te d'Ivoire" },
|
||||||
|
{ code: "CU", name: "\u041A\u0443\u0431\u0430", nameEn: "Cuba" },
|
||||||
|
{ code: "KW", name: "\u041A\u0443\u0432\u0435\u0439\u0442", nameEn: "Kuwait" },
|
||||||
|
{ code: "LA", name: "\u041B\u0430\u043E\u0441", nameEn: "Laos" },
|
||||||
|
{ code: "LV", name: "\u041B\u0430\u0442\u0432\u0438\u044F", nameEn: "Latvia" },
|
||||||
|
{ code: "LR", name: "\u041B\u0438\u0431\u0435\u0440\u0438\u044F", nameEn: "Liberia" },
|
||||||
|
{ code: "LB", name: "\u041B\u0438\u0432\u0430\u043D", nameEn: "Lebanon" },
|
||||||
|
{ code: "LY", name: "\u041B\u0438\u0432\u0438\u044F", nameEn: "Libya" },
|
||||||
|
{ code: "LT", name: "\u041B\u0438\u0442\u0432\u0430", nameEn: "Lithuania" },
|
||||||
|
{ code: "LI", name: "\u041B\u0438\u0445\u0442\u0435\u043D\u0448\u0442\u0435\u0439\u043D", nameEn: "Liechtenstein" },
|
||||||
|
{ code: "LU", name: "\u041B\u044E\u043A\u0441\u0435\u043C\u0431\u0443\u0440\u0433", nameEn: "Luxembourg" },
|
||||||
|
{ code: "MU", name: "\u041C\u0430\u0432\u0440\u0438\u043A\u0438\u0439", nameEn: "Mauritius" },
|
||||||
|
{ code: "MR", name: "\u041C\u0430\u0432\u0440\u0438\u0442\u0430\u043D\u0438\u044F", nameEn: "Mauritania" },
|
||||||
|
{ code: "MG", name: "\u041C\u0430\u0434\u0430\u0433\u0430\u0441\u043A\u0430\u0440", nameEn: "Madagascar" },
|
||||||
|
{ code: "MO", name: "\u041C\u0430\u043A\u0430\u043E", nameEn: "Macao" },
|
||||||
|
{ code: "MW", name: "\u041C\u0430\u043B\u0430\u0432\u0438", nameEn: "Malawi" },
|
||||||
|
{ code: "MY", name: "\u041C\u0430\u043B\u0430\u0439\u0437\u0438\u044F", nameEn: "Malaysia" },
|
||||||
|
{ code: "ML", name: "\u041C\u0430\u043B\u0438", nameEn: "Mali" },
|
||||||
|
{ code: "MT", name: "\u041C\u0430\u043B\u044C\u0442\u0430", nameEn: "Malta" },
|
||||||
|
{ code: "MA", name: "\u041C\u0430\u0440\u043E\u043A\u043A\u043E", nameEn: "Morocco" },
|
||||||
|
{ code: "MX", name: "\u041C\u0435\u043A\u0441\u0438\u043A\u0430", nameEn: "Mexico" },
|
||||||
|
{ code: "MZ", name: "\u041C\u043E\u0437\u0430\u043C\u0431\u0438\u043A", nameEn: "Mozambique" },
|
||||||
|
{ code: "MD", name: "\u041C\u043E\u043B\u0434\u0430\u0432\u0438\u044F", nameEn: "Moldova" },
|
||||||
|
{ code: "MC", name: "\u041C\u043E\u043D\u0430\u043A\u043E", nameEn: "Monaco" },
|
||||||
|
{ code: "MN", name: "\u041C\u043E\u043D\u0433\u043E\u043B\u0438\u044F", nameEn: "Mongolia" },
|
||||||
|
{ code: "MM", name: "\u041C\u044C\u044F\u043D\u043C\u0430", nameEn: "Myanmar" },
|
||||||
|
{ code: "NA", name: "\u041D\u0430\u043C\u0438\u0431\u0438\u044F", nameEn: "Namibia" },
|
||||||
|
{ code: "NP", name: "\u041D\u0435\u043F\u0430\u043B", nameEn: "Nepal" },
|
||||||
|
{ code: "NE", name: "\u041D\u0438\u0433\u0435\u0440", nameEn: "Niger" },
|
||||||
|
{ code: "NG", name: "\u041D\u0438\u0433\u0435\u0440\u0438\u044F", nameEn: "Nigeria" },
|
||||||
|
{ code: "NL", name: "\u041D\u0438\u0434\u0435\u0440\u043B\u0430\u043D\u0434\u044B", nameEn: "Netherlands" },
|
||||||
|
{ code: "NI", name: "\u041D\u0438\u043A\u0430\u0440\u0430\u0433\u0443\u0430", nameEn: "Nicaragua" },
|
||||||
|
{ code: "NZ", name: "\u041D\u043E\u0432\u0430\u044F \u0417\u0435\u043B\u0430\u043D\u0434\u0438\u044F", nameEn: "New Zealand" },
|
||||||
|
{ code: "NO", name: "\u041D\u043E\u0440\u0432\u0435\u0433\u0438\u044F", nameEn: "Norway" },
|
||||||
|
{ code: "AE", name: "\u041E\u0410\u042D", nameEn: "United Arab Emirates" },
|
||||||
|
{ code: "OM", name: "\u041E\u043C\u0430\u043D", nameEn: "Oman" },
|
||||||
|
{ code: "PK", name: "\u041F\u0430\u043A\u0438\u0441\u0442\u0430\u043D", nameEn: "Pakistan" },
|
||||||
|
{ code: "PA", name: "\u041F\u0430\u043D\u0430\u043C\u0430", nameEn: "Panama" },
|
||||||
|
{ code: "PG", name: "\u041F\u0430\u043F\u0443\u0430 \u2014 \u041D\u043E\u0432\u0430\u044F \u0413\u0432\u0438\u043D\u0435\u044F", nameEn: "Papua New Guinea" },
|
||||||
|
{ code: "PY", name: "\u041F\u0430\u0440\u0430\u0433\u0432\u0430\u0439", nameEn: "Paraguay" },
|
||||||
|
{ code: "PE", name: "\u041F\u0435\u0440\u0443", nameEn: "Peru" },
|
||||||
|
{ code: "PL", name: "\u041F\u043E\u043B\u044C\u0448\u0430", nameEn: "Poland" },
|
||||||
|
{ code: "PT", name: "\u041F\u043E\u0440\u0442\u0443\u0433\u0430\u043B\u0438\u044F", nameEn: "Portugal" },
|
||||||
|
{ code: "RU", name: "\u0420\u043E\u0441\u0441\u0438\u044F", nameEn: "Russia" },
|
||||||
|
{ code: "RW", name: "\u0420\u0443\u0430\u043D\u0434\u0430", nameEn: "Rwanda" },
|
||||||
|
{ code: "RO", name: "\u0420\u0443\u043C\u044B\u043D\u0438\u044F", nameEn: "Romania" },
|
||||||
|
{ code: "SV", name: "\u0421\u0430\u043B\u044C\u0432\u0430\u0434\u043E\u0440", nameEn: "El Salvador" },
|
||||||
|
{ code: "SA", name: "\u0421\u0430\u0443\u0434\u043E\u0432\u0441\u043A\u0430\u044F \u0410\u0440\u0430\u0432\u0438\u044F", nameEn: "Saudi Arabia" },
|
||||||
|
{ code: "MK", name: "\u0421\u0435\u0432\u0435\u0440\u043D\u0430\u044F \u041C\u0430\u043A\u0435\u0434\u043E\u043D\u0438\u044F", nameEn: "North Macedonia" },
|
||||||
|
{ code: "SC", name: "\u0421\u0435\u0439\u0448\u0435\u043B\u044B", nameEn: "Seychelles" },
|
||||||
|
{ code: "SN", name: "\u0421\u0435\u043D\u0435\u0433\u0430\u043B", nameEn: "Senegal" },
|
||||||
|
{ code: "RS", name: "\u0421\u0435\u0440\u0431\u0438\u044F", nameEn: "Serbia" },
|
||||||
|
{ code: "SG", name: "\u0421\u0438\u043D\u0433\u0430\u043F\u0443\u0440", nameEn: "Singapore" },
|
||||||
|
{ code: "SK", name: "\u0421\u043B\u043E\u0432\u0430\u043A\u0438\u044F", nameEn: "Slovakia" },
|
||||||
|
{ code: "SI", name: "\u0421\u043B\u043E\u0432\u0435\u043D\u0438\u044F", nameEn: "Slovenia" },
|
||||||
|
{ code: "SO", name: "\u0421\u043E\u043C\u0430\u043B\u0438", nameEn: "Somalia" },
|
||||||
|
{ code: "SD", name: "\u0421\u0443\u0434\u0430\u043D", nameEn: "Sudan" },
|
||||||
|
{ code: "US", name: "\u0421\u0428\u0410", nameEn: "United States" },
|
||||||
|
{ code: "TJ", name: "\u0422\u0430\u0434\u0436\u0438\u043A\u0438\u0441\u0442\u0430\u043D", nameEn: "Tajikistan" },
|
||||||
|
{ code: "TH", name: "\u0422\u0430\u0438\u043B\u0430\u043D\u0434", nameEn: "Thailand" },
|
||||||
|
{ code: "TZ", name: "\u0422\u0430\u043D\u0437\u0430\u043D\u0438\u044F", nameEn: "Tanzania" },
|
||||||
|
{ code: "TG", name: "\u0422\u043E\u0433\u043E", nameEn: "Togo" },
|
||||||
|
{ code: "TT", name: "\u0422\u0440\u0438\u043D\u0438\u0434\u0430\u0434 \u0438 \u0422\u043E\u0431\u0430\u0433\u043E", nameEn: "Trinidad and Tobago" },
|
||||||
|
{ code: "TV", name: "\u0422\u0443\u0432\u0430\u043B\u0443", nameEn: "Tuvalu" },
|
||||||
|
{ code: "TN", name: "\u0422\u0443\u043D\u0438\u0441", nameEn: "Tunisia" },
|
||||||
|
{ code: "TM", name: "\u0422\u0443\u0440\u043A\u043C\u0435\u043D\u0438\u044F", nameEn: "Turkmenistan" },
|
||||||
|
{ code: "TR", name: "\u0422\u0443\u0440\u0446\u0438\u044F", nameEn: "Turkey" },
|
||||||
|
{ code: "UG", name: "\u0423\u0433\u0430\u043D\u0434\u0430", nameEn: "Uganda" },
|
||||||
|
{ code: "UZ", name: "\u0423\u0437\u0431\u0435\u043A\u0438\u0441\u0442\u0430\u043D", nameEn: "Uzbekistan" },
|
||||||
|
{ code: "UA", name: "\u0423\u043A\u0440\u0430\u0438\u043D\u0430", nameEn: "Ukraine" },
|
||||||
|
{ code: "UY", name: "\u0423\u0440\u0443\u0433\u0432\u0430\u0439", nameEn: "Uruguay" },
|
||||||
|
{ code: "FJ", name: "\u0424\u0438\u0434\u0436\u0438", nameEn: "Fiji" },
|
||||||
|
{ code: "PH", name: "\u0424\u0438\u043B\u0438\u043F\u043F\u0438\u043D\u044B", nameEn: "Philippines" },
|
||||||
|
{ code: "FI", name: "\u0424\u0438\u043D\u043B\u044F\u043D\u0434\u0438\u044F", nameEn: "Finland" },
|
||||||
|
{ code: "FR", name: "\u0424\u0440\u0430\u043D\u0446\u0438\u044F", nameEn: "France" },
|
||||||
|
{ code: "HR", name: "\u0425\u043E\u0440\u0432\u0430\u0442\u0438\u044F", nameEn: "Croatia" },
|
||||||
|
{ code: "TD", name: "\u0427\u0430\u0434", nameEn: "Chad" },
|
||||||
|
{ code: "ME", name: "\u0427\u0435\u0440\u043D\u043E\u0433\u043E\u0440\u0438\u044F", nameEn: "Montenegro" },
|
||||||
|
{ code: "CZ", name: "\u0427\u0435\u0445\u0438\u044F", nameEn: "Czechia" },
|
||||||
|
{ code: "CL", name: "\u0427\u0438\u043B\u0438", nameEn: "Chile" },
|
||||||
|
{ code: "CH", name: "\u0428\u0432\u0435\u0439\u0446\u0430\u0440\u0438\u044F", nameEn: "Switzerland" },
|
||||||
|
{ code: "SE", name: "\u0428\u0432\u0435\u0446\u0438\u044F", nameEn: "Sweden" },
|
||||||
|
{ code: "LK", name: "\u0428\u0440\u0438-\u041B\u0430\u043D\u043A\u0430", nameEn: "Sri Lanka" },
|
||||||
|
{ code: "EC", name: "\u042D\u043A\u0432\u0430\u0434\u043E\u0440", nameEn: "Ecuador" },
|
||||||
|
{ code: "GQ", name: "\u042D\u043A\u0432\u0430\u0442\u043E\u0440\u0438\u0430\u043B\u044C\u043D\u0430\u044F \u0413\u0432\u0438\u043D\u0435\u044F", nameEn: "Equatorial Guinea" },
|
||||||
|
{ code: "ER", name: "\u042D\u0440\u0438\u0442\u0440\u0435\u044F", nameEn: "Eritrea" },
|
||||||
|
{ code: "EE", name: "\u042D\u0441\u0442\u043E\u043D\u0438\u044F", nameEn: "Estonia" },
|
||||||
|
{ code: "ET", name: "\u042D\u0444\u0438\u043E\u043F\u0438\u044F", nameEn: "Ethiopia" },
|
||||||
|
{ code: "ZA", name: "\u042E\u0410\u0420", nameEn: "South Africa" },
|
||||||
|
{ code: "JM", name: "\u042F\u043C\u0430\u0439\u043A\u0430", nameEn: "Jamaica" },
|
||||||
|
{ code: "JP", name: "\u042F\u043F\u043E\u043D\u0438\u044F", nameEn: "Japan" }
|
||||||
|
];
|
||||||
|
var COUNTRY_BY_CODE = Object.fromEntries(
|
||||||
|
COUNTRIES.map((c) => [c.code, c])
|
||||||
|
);
|
||||||
|
var COUNTRY_BY_NAME_RU = Object.fromEntries(
|
||||||
|
COUNTRIES.map((c) => [c.name.toLowerCase(), c])
|
||||||
|
);
|
||||||
|
|
||||||
|
// src/geo/cities.ts
|
||||||
|
var CITIES = [
|
||||||
|
// Россия
|
||||||
|
{ countryCode: "RU", name: "\u041C\u043E\u0441\u043A\u0432\u0430", nameEn: "Moscow" },
|
||||||
|
{ countryCode: "RU", name: "\u0421\u0430\u043D\u043A\u0442-\u041F\u0435\u0442\u0435\u0440\u0431\u0443\u0440\u0433", nameEn: "Saint Petersburg" },
|
||||||
|
{ countryCode: "RU", name: "\u041D\u043E\u0432\u043E\u0441\u0438\u0431\u0438\u0440\u0441\u043A", nameEn: "Novosibirsk" },
|
||||||
|
{ countryCode: "RU", name: "\u0415\u043A\u0430\u0442\u0435\u0440\u0438\u043D\u0431\u0443\u0440\u0433", nameEn: "Yekaterinburg" },
|
||||||
|
{ countryCode: "RU", name: "\u041A\u0430\u0437\u0430\u043D\u044C", nameEn: "Kazan" },
|
||||||
|
{ countryCode: "RU", name: "\u041D\u0438\u0436\u043D\u0438\u0439 \u041D\u043E\u0432\u0433\u043E\u0440\u043E\u0434", nameEn: "Nizhny Novgorod" },
|
||||||
|
{ countryCode: "RU", name: "\u0421\u0430\u043C\u0430\u0440\u0430", nameEn: "Samara" },
|
||||||
|
{ countryCode: "RU", name: "\u0420\u043E\u0441\u0442\u043E\u0432-\u043D\u0430-\u0414\u043E\u043D\u0443", nameEn: "Rostov-on-Don" },
|
||||||
|
{ countryCode: "RU", name: "\u0423\u0444\u0430", nameEn: "Ufa" },
|
||||||
|
{ countryCode: "RU", name: "\u041A\u0440\u0430\u0441\u043D\u043E\u0434\u0430\u0440", nameEn: "Krasnodar" },
|
||||||
|
{ countryCode: "RU", name: "\u0421\u043E\u0447\u0438", nameEn: "Sochi" },
|
||||||
|
{ countryCode: "RU", name: "\u0412\u043B\u0430\u0434\u0438\u0432\u043E\u0441\u0442\u043E\u043A", nameEn: "Vladivostok" },
|
||||||
|
{ countryCode: "RU", name: "\u0418\u0440\u043A\u0443\u0442\u0441\u043A", nameEn: "Irkutsk" },
|
||||||
|
{ countryCode: "RU", name: "\u0425\u0430\u0431\u0430\u0440\u043E\u0432\u0441\u043A", nameEn: "Khabarovsk" },
|
||||||
|
{ countryCode: "RU", name: "\u0422\u044E\u043C\u0435\u043D\u044C", nameEn: "Tyumen" },
|
||||||
|
// Нидерланды
|
||||||
|
{ countryCode: "NL", name: "\u0410\u043C\u0441\u0442\u0435\u0440\u0434\u0430\u043C", nameEn: "Amsterdam" },
|
||||||
|
{ countryCode: "NL", name: "\u0420\u043E\u0442\u0442\u0435\u0440\u0434\u0430\u043C", nameEn: "Rotterdam" },
|
||||||
|
{ countryCode: "NL", name: "\u0413\u0430\u0430\u0433\u0430", nameEn: "The Hague" },
|
||||||
|
// Германия
|
||||||
|
{ countryCode: "DE", name: "\u0424\u0440\u0430\u043D\u043A\u0444\u0443\u0440\u0442", nameEn: "Frankfurt" },
|
||||||
|
{ countryCode: "DE", name: "\u0411\u0435\u0440\u043B\u0438\u043D", nameEn: "Berlin" },
|
||||||
|
{ countryCode: "DE", name: "\u041C\u044E\u043D\u0445\u0435\u043D", nameEn: "Munich" },
|
||||||
|
{ countryCode: "DE", name: "\u0413\u0430\u043C\u0431\u0443\u0440\u0433", nameEn: "Hamburg" },
|
||||||
|
{ countryCode: "DE", name: "\u041A\u0451\u043B\u044C\u043D", nameEn: "Cologne" },
|
||||||
|
{ countryCode: "DE", name: "\u0414\u044E\u0441\u0441\u0435\u043B\u044C\u0434\u043E\u0440\u0444", nameEn: "D\xFCsseldorf" },
|
||||||
|
{ countryCode: "DE", name: "\u0428\u0442\u0443\u0442\u0433\u0430\u0440\u0442", nameEn: "Stuttgart" },
|
||||||
|
// Финляндия
|
||||||
|
{ countryCode: "FI", name: "\u0425\u0435\u043B\u044C\u0441\u0438\u043D\u043A\u0438", nameEn: "Helsinki" },
|
||||||
|
{ countryCode: "FI", name: "\u042D\u0441\u043F\u043E\u043E", nameEn: "Espoo" },
|
||||||
|
{ countryCode: "FI", name: "\u0422\u0430\u043C\u043F\u0435\u0440\u0435", nameEn: "Tampere" },
|
||||||
|
// Швеция
|
||||||
|
{ countryCode: "SE", name: "\u0421\u0442\u043E\u043A\u0433\u043E\u043B\u044C\u043C", nameEn: "Stockholm" },
|
||||||
|
{ countryCode: "SE", name: "\u0413\u0451\u0442\u0435\u0431\u043E\u0440\u0433", nameEn: "Gothenburg" },
|
||||||
|
{ countryCode: "SE", name: "\u041C\u0430\u043B\u044C\u043C\u0451", nameEn: "Malm\xF6" },
|
||||||
|
// Норвегия
|
||||||
|
{ countryCode: "NO", name: "\u041E\u0441\u043B\u043E", nameEn: "Oslo" },
|
||||||
|
{ countryCode: "NO", name: "\u0411\u0435\u0440\u0433\u0435\u043D", nameEn: "Bergen" },
|
||||||
|
// Дания
|
||||||
|
{ countryCode: "DK", name: "\u041A\u043E\u043F\u0435\u043D\u0433\u0430\u0433\u0435\u043D", nameEn: "Copenhagen" },
|
||||||
|
{ countryCode: "DK", name: "\u041E\u0440\u0445\u0443\u0441", nameEn: "Aarhus" },
|
||||||
|
// Великобритания
|
||||||
|
{ countryCode: "GB", name: "\u041B\u043E\u043D\u0434\u043E\u043D", nameEn: "London" },
|
||||||
|
{ countryCode: "GB", name: "\u041C\u0430\u043D\u0447\u0435\u0441\u0442\u0435\u0440", nameEn: "Manchester" },
|
||||||
|
{ countryCode: "GB", name: "\u0413\u043B\u0430\u0437\u0433\u043E", nameEn: "Glasgow" },
|
||||||
|
{ countryCode: "GB", name: "\u041A\u0430\u0440\u0434\u0438\u0444\u0444", nameEn: "Cardiff" },
|
||||||
|
// Франция
|
||||||
|
{ countryCode: "FR", name: "\u041F\u0430\u0440\u0438\u0436", nameEn: "Paris" },
|
||||||
|
{ countryCode: "FR", name: "\u041C\u0430\u0440\u0441\u0435\u043B\u044C", nameEn: "Marseille" },
|
||||||
|
{ countryCode: "FR", name: "\u041B\u0438\u043E\u043D", nameEn: "Lyon" },
|
||||||
|
{ countryCode: "FR", name: "\u0421\u0442\u0440\u0430\u0441\u0431\u0443\u0440\u0433", nameEn: "Strasbourg" },
|
||||||
|
// Испания
|
||||||
|
{ countryCode: "ES", name: "\u041C\u0430\u0434\u0440\u0438\u0434", nameEn: "Madrid" },
|
||||||
|
{ countryCode: "ES", name: "\u0411\u0430\u0440\u0441\u0435\u043B\u043E\u043D\u0430", nameEn: "Barcelona" },
|
||||||
|
{ countryCode: "ES", name: "\u0412\u0430\u043B\u0435\u043D\u0441\u0438\u044F", nameEn: "Valencia" },
|
||||||
|
// Италия
|
||||||
|
{ countryCode: "IT", name: "\u0420\u0438\u043C", nameEn: "Rome" },
|
||||||
|
{ countryCode: "IT", name: "\u041C\u0438\u043B\u0430\u043D", nameEn: "Milan" },
|
||||||
|
{ countryCode: "IT", name: "\u0422\u0443\u0440\u0438\u043D", nameEn: "Turin" },
|
||||||
|
// Чехия
|
||||||
|
{ countryCode: "CZ", name: "\u041F\u0440\u0430\u0433\u0430", nameEn: "Prague" },
|
||||||
|
{ countryCode: "CZ", name: "\u0411\u0440\u043D\u043E", nameEn: "Brno" },
|
||||||
|
// Польша
|
||||||
|
{ countryCode: "PL", name: "\u0412\u0430\u0440\u0448\u0430\u0432\u0430", nameEn: "Warsaw" },
|
||||||
|
{ countryCode: "PL", name: "\u041A\u0440\u0430\u043A\u043E\u0432", nameEn: "Krak\xF3w" },
|
||||||
|
{ countryCode: "PL", name: "\u0412\u0440\u043E\u0446\u043B\u0430\u0432", nameEn: "Wroc\u0142aw" },
|
||||||
|
{ countryCode: "PL", name: "\u0413\u0434\u0430\u043D\u044C\u0441\u043A", nameEn: "Gda\u0144sk" },
|
||||||
|
// Швейцария
|
||||||
|
{ countryCode: "CH", name: "\u0426\u044E\u0440\u0438\u0445", nameEn: "Zurich" },
|
||||||
|
{ countryCode: "CH", name: "\u0416\u0435\u043D\u0435\u0432\u0430", nameEn: "Geneva" },
|
||||||
|
{ countryCode: "CH", name: "\u0411\u0430\u0437\u0435\u043B\u044C", nameEn: "Basel" },
|
||||||
|
// Бельгия
|
||||||
|
{ countryCode: "BE", name: "\u0411\u0440\u044E\u0441\u0441\u0435\u043B\u044C", nameEn: "Brussels" },
|
||||||
|
{ countryCode: "BE", name: "\u0410\u043D\u0442\u0432\u0435\u0440\u043F\u0435\u043D", nameEn: "Antwerp" },
|
||||||
|
// Австрия
|
||||||
|
{ countryCode: "AT", name: "\u0412\u0435\u043D\u0430", nameEn: "Vienna" },
|
||||||
|
{ countryCode: "AT", name: "\u0413\u0440\u0430\u0446", nameEn: "Graz" },
|
||||||
|
// Латвия
|
||||||
|
{ countryCode: "LV", name: "\u0420\u0438\u0433\u0430", nameEn: "Riga" },
|
||||||
|
// Литва
|
||||||
|
{ countryCode: "LT", name: "\u0412\u0438\u043B\u044C\u043D\u044E\u0441", nameEn: "Vilnius" },
|
||||||
|
{ countryCode: "LT", name: "\u041A\u0430\u0443\u043D\u0430\u0441", nameEn: "Kaunas" },
|
||||||
|
// Эстония
|
||||||
|
{ countryCode: "EE", name: "\u0422\u0430\u043B\u043B\u0438\u043D", nameEn: "Tallinn" },
|
||||||
|
// Украина
|
||||||
|
{ countryCode: "UA", name: "\u041A\u0438\u0435\u0432", nameEn: "Kyiv" },
|
||||||
|
{ countryCode: "UA", name: "\u0425\u0430\u0440\u044C\u043A\u043E\u0432", nameEn: "Kharkiv" },
|
||||||
|
{ countryCode: "UA", name: "\u041E\u0434\u0435\u0441\u0441\u0430", nameEn: "Odesa" },
|
||||||
|
{ countryCode: "UA", name: "\u041B\u044C\u0432\u043E\u0432", nameEn: "Lviv" },
|
||||||
|
// Казахстан
|
||||||
|
{ countryCode: "KZ", name: "\u0410\u043B\u043C\u0430\u0442\u044B", nameEn: "Almaty" },
|
||||||
|
{ countryCode: "KZ", name: "\u0410\u0441\u0442\u0430\u043D\u0430", nameEn: "Astana" },
|
||||||
|
// Беларусь
|
||||||
|
{ countryCode: "BY", name: "\u041C\u0438\u043D\u0441\u043A", nameEn: "Minsk" },
|
||||||
|
// США
|
||||||
|
{ countryCode: "US", name: "\u041D\u044C\u044E-\u0419\u043E\u0440\u043A", nameEn: "New York" },
|
||||||
|
{ countryCode: "US", name: "\u041B\u043E\u0441-\u0410\u043D\u0434\u0436\u0435\u043B\u0435\u0441", nameEn: "Los Angeles" },
|
||||||
|
{ countryCode: "US", name: "\u0427\u0438\u043A\u0430\u0433\u043E", nameEn: "Chicago" },
|
||||||
|
{ countryCode: "US", name: "\u0425\u044C\u044E\u0441\u0442\u043E\u043D", nameEn: "Houston" },
|
||||||
|
{ countryCode: "US", name: "\u0414\u0430\u043B\u043B\u0430\u0441", nameEn: "Dallas" },
|
||||||
|
{ countryCode: "US", name: "\u041C\u0430\u0439\u0430\u043C\u0438", nameEn: "Miami" },
|
||||||
|
{ countryCode: "US", name: "\u0421\u0438\u044D\u0442\u043B", nameEn: "Seattle" },
|
||||||
|
{ countryCode: "US", name: "\u0421\u0430\u043D-\u0424\u0440\u0430\u043D\u0446\u0438\u0441\u043A\u043E", nameEn: "San Francisco" },
|
||||||
|
{ countryCode: "US", name: "\u0412\u0430\u0448\u0438\u043D\u0433\u0442\u043E\u043D", nameEn: "Washington" },
|
||||||
|
{ countryCode: "US", name: "\u0410\u0442\u043B\u0430\u043D\u0442\u0430", nameEn: "Atlanta" },
|
||||||
|
{ countryCode: "US", name: "\u0414\u0435\u043D\u0432\u0435\u0440", nameEn: "Denver" },
|
||||||
|
// Канада
|
||||||
|
{ countryCode: "CA", name: "\u0422\u043E\u0440\u043E\u043D\u0442\u043E", nameEn: "Toronto" },
|
||||||
|
{ countryCode: "CA", name: "\u041C\u043E\u043D\u0440\u0435\u0430\u043B\u044C", nameEn: "Montreal" },
|
||||||
|
{ countryCode: "CA", name: "\u0412\u0430\u043D\u043A\u0443\u0432\u0435\u0440", nameEn: "Vancouver" },
|
||||||
|
// Сингапур
|
||||||
|
{ countryCode: "SG", name: "\u0421\u0438\u043D\u0433\u0430\u043F\u0443\u0440", nameEn: "Singapore" },
|
||||||
|
// Япония
|
||||||
|
{ countryCode: "JP", name: "\u0422\u043E\u043A\u0438\u043E", nameEn: "Tokyo" },
|
||||||
|
{ countryCode: "JP", name: "\u041E\u0441\u0430\u043A\u0430", nameEn: "Osaka" },
|
||||||
|
// Южная Корея
|
||||||
|
{ countryCode: "KR", name: "\u0421\u0435\u0443\u043B", nameEn: "Seoul" },
|
||||||
|
// Китай
|
||||||
|
{ countryCode: "CN", name: "\u041F\u0435\u043A\u0438\u043D", nameEn: "Beijing" },
|
||||||
|
{ countryCode: "CN", name: "\u0428\u0430\u043D\u0445\u0430\u0439", nameEn: "Shanghai" },
|
||||||
|
{ countryCode: "CN", name: "\u0413\u0443\u0430\u043D\u0447\u0436\u043E\u0443", nameEn: "Guangzhou" },
|
||||||
|
{ countryCode: "CN", name: "\u0428\u044D\u043D\u044C\u0447\u0436\u044D\u043D\u044C", nameEn: "Shenzhen" },
|
||||||
|
{ countryCode: "CN", name: "\u0413\u043E\u043D\u043A\u043E\u043D\u0433", nameEn: "Hong Kong" },
|
||||||
|
// Индия
|
||||||
|
{ countryCode: "IN", name: "\u041C\u0443\u043C\u0431\u0430\u0438", nameEn: "Mumbai" },
|
||||||
|
{ countryCode: "IN", name: "\u0414\u0435\u043B\u0438", nameEn: "Delhi" },
|
||||||
|
{ countryCode: "IN", name: "\u0411\u0430\u043D\u0433\u0430\u043B\u043E\u0440", nameEn: "Bangalore" },
|
||||||
|
{ countryCode: "IN", name: "\u0427\u0435\u043D\u043D\u0430\u0438", nameEn: "Chennai" },
|
||||||
|
// ОАЭ
|
||||||
|
{ countryCode: "AE", name: "\u0414\u0443\u0431\u0430\u0439", nameEn: "Dubai" },
|
||||||
|
{ countryCode: "AE", name: "\u0410\u0431\u0443-\u0414\u0430\u0431\u0438", nameEn: "Abu Dhabi" },
|
||||||
|
// Турция
|
||||||
|
{ countryCode: "TR", name: "\u0421\u0442\u0430\u043C\u0431\u0443\u043B", nameEn: "Istanbul" },
|
||||||
|
{ countryCode: "TR", name: "\u0410\u043D\u043A\u0430\u0440\u0430", nameEn: "Ankara" },
|
||||||
|
{ countryCode: "TR", name: "\u0418\u0437\u043C\u0438\u0440", nameEn: "Izmir" },
|
||||||
|
// Бразилия
|
||||||
|
{ countryCode: "BR", name: "\u0421\u0430\u043D-\u041F\u0430\u0443\u043B\u0443", nameEn: "S\xE3o Paulo" },
|
||||||
|
{ countryCode: "BR", name: "\u0420\u0438\u043E-\u0434\u0435-\u0416\u0430\u043D\u0435\u0439\u0440\u043E", nameEn: "Rio de Janeiro" },
|
||||||
|
// Австралия
|
||||||
|
{ countryCode: "AU", name: "\u0421\u0438\u0434\u043D\u0435\u0439", nameEn: "Sydney" },
|
||||||
|
{ countryCode: "AU", name: "\u041C\u0435\u043B\u044C\u0431\u0443\u0440\u043D", nameEn: "Melbourne" },
|
||||||
|
// Болгария
|
||||||
|
{ countryCode: "BG", name: "\u0421\u043E\u0444\u0438\u044F", nameEn: "Sofia" },
|
||||||
|
// Румыния
|
||||||
|
{ countryCode: "RO", name: "\u0411\u0443\u0445\u0430\u0440\u0435\u0441\u0442", nameEn: "Bucharest" },
|
||||||
|
// Венгрия
|
||||||
|
{ countryCode: "HU", name: "\u0411\u0443\u0434\u0430\u043F\u0435\u0448\u0442", nameEn: "Budapest" },
|
||||||
|
// Греция
|
||||||
|
{ countryCode: "GR", name: "\u0410\u0444\u0438\u043D\u044B", nameEn: "Athens" },
|
||||||
|
// Португалия
|
||||||
|
{ countryCode: "PT", name: "\u041B\u0438\u0441\u0441\u0430\u0431\u043E\u043D", nameEn: "Lisbon" },
|
||||||
|
// Ирландия
|
||||||
|
{ countryCode: "IE", name: "\u0414\u0443\u0431\u043B\u0438\u043D", nameEn: "Dublin" },
|
||||||
|
// Израиль
|
||||||
|
{ countryCode: "IL", name: "\u0422\u0435\u043B\u044C-\u0410\u0432\u0438\u0432", nameEn: "Tel Aviv" }
|
||||||
|
];
|
||||||
|
var CITY_BY_NAME_RU = Object.fromEntries(
|
||||||
|
CITIES.map((c) => [c.name.toLowerCase(), c])
|
||||||
|
);
|
||||||
|
function resolveCityRef(cityName) {
|
||||||
|
return CITY_BY_NAME_RU[cityName.trim().toLowerCase()];
|
||||||
|
}
|
||||||
|
function resolveCountryForCity(cityName) {
|
||||||
|
const city = resolveCityRef(cityName);
|
||||||
|
return city ? COUNTRY_BY_CODE[city.countryCode] : void 0;
|
||||||
|
}
|
||||||
|
function cityMatchesCountry(cityName, countryName, rows) {
|
||||||
|
const countryCode = COUNTRY_BY_NAME_RU[countryName.trim().toLowerCase()]?.code;
|
||||||
|
if (!countryCode) return true;
|
||||||
|
const catalog = resolveCityRef(cityName);
|
||||||
|
if (catalog) return catalog.countryCode === countryCode;
|
||||||
|
return (rows ?? []).some(
|
||||||
|
(row) => row.city?.trim().toLowerCase() === cityName.trim().toLowerCase() && COUNTRY_BY_NAME_RU[(row.country || "").trim().toLowerCase()]?.code === countryCode
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function buildCityOptions(rows, countryName, options) {
|
||||||
|
const includeCatalog = options?.includeCatalog ?? true;
|
||||||
|
const names = /* @__PURE__ */ new Set();
|
||||||
|
const countryCode = countryName?.trim() ? COUNTRY_BY_NAME_RU[countryName.trim().toLowerCase()]?.code : void 0;
|
||||||
|
const belongsToCountry = (city, rowCountry) => {
|
||||||
|
if (!countryCode) return true;
|
||||||
|
const catalog = resolveCityRef(city);
|
||||||
|
if (catalog) return catalog.countryCode === countryCode;
|
||||||
|
if (rowCountry?.trim()) {
|
||||||
|
return COUNTRY_BY_NAME_RU[rowCountry.trim().toLowerCase()]?.code === countryCode;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
for (const row of rows ?? []) {
|
||||||
|
const city = (row.city || "").trim();
|
||||||
|
if (!city) continue;
|
||||||
|
if (belongsToCountry(city, row.country ?? void 0)) {
|
||||||
|
names.add(city);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (includeCatalog) {
|
||||||
|
for (const { name } of listCities(countryCode)) {
|
||||||
|
names.add(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...names].sort((a, b) => a.localeCompare(b, "ru")).map((name) => ({ value: name, label: name }));
|
||||||
|
}
|
||||||
|
function resolveCountryForCityFromRows(cityName, rows) {
|
||||||
|
const trimmed = cityName.trim();
|
||||||
|
if (!trimmed) return void 0;
|
||||||
|
const fromCatalog = resolveCountryForCity(trimmed);
|
||||||
|
if (fromCatalog) return fromCatalog.name;
|
||||||
|
const row = (rows ?? []).find(
|
||||||
|
(r) => r.city?.trim().toLowerCase() === trimmed.toLowerCase() && r.country?.trim()
|
||||||
|
);
|
||||||
|
return row?.country?.trim();
|
||||||
|
}
|
||||||
|
function listCities(countryCode) {
|
||||||
|
return CITIES.filter((c) => !countryCode || c.countryCode === countryCode).map((c) => ({
|
||||||
|
...c,
|
||||||
|
country: COUNTRY_BY_CODE[c.countryCode]
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
function countriesWithCities() {
|
||||||
|
const codes = new Set(CITIES.map((c) => c.countryCode));
|
||||||
|
return COUNTRIES.filter((c) => codes.has(c.code));
|
||||||
|
}
|
||||||
|
|
||||||
|
// src/geo/loc-codes.ts
|
||||||
|
var CANONICAL_LOC_CODES = {
|
||||||
|
\u043C\u043E\u0441\u043A\u0432\u0430: "msk",
|
||||||
|
\u0444\u0440\u0430\u043D\u043A\u0444\u0443\u0440\u0442: "fra",
|
||||||
|
\u0430\u043C\u0441\u0442\u0435\u0440\u0434\u0430\u043C: "ams",
|
||||||
|
\u0445\u0435\u043B\u044C\u0441\u0438\u043D\u043A\u0438: "hel",
|
||||||
|
\u043F\u0430\u0440\u0438\u0436: "par",
|
||||||
|
"\u0441\u0430\u043D\u043A\u0442-\u043F\u0435\u0442\u0435\u0440\u0431\u0443\u0440\u0433": "led",
|
||||||
|
\u0431\u0435\u0440\u043B\u0438\u043D: "ber",
|
||||||
|
\u043C\u044E\u043D\u0445\u0435\u043D: "muc",
|
||||||
|
\u043B\u043E\u043D\u0434\u043E\u043D: "lon",
|
||||||
|
"\u043D\u044C\u044E-\u0439\u043E\u0440\u043A": "nyc",
|
||||||
|
\u0442\u043E\u043A\u0438\u043E: "tyo",
|
||||||
|
\u0441\u0438\u043D\u0433\u0430\u043F\u0443\u0440: "sin",
|
||||||
|
\u0434\u0443\u0431\u0430\u0439: "dxb",
|
||||||
|
\u0432\u0430\u0440\u0448\u0430\u0432\u0430: "waw",
|
||||||
|
\u043F\u0440\u0430\u0433\u0430: "prg",
|
||||||
|
\u0432\u0435\u043D\u0430: "vie",
|
||||||
|
\u0440\u0438\u0433\u0430: "rix",
|
||||||
|
\u0442\u0430\u043B\u043B\u0438\u043D: "tll",
|
||||||
|
\u043C\u0438\u043D\u0441\u043A: "msq",
|
||||||
|
\u043A\u0438\u0435\u0432: "iev",
|
||||||
|
\u0441\u0442\u043E\u043A\u0433\u043E\u043B\u044C\u043C: "arn",
|
||||||
|
\u043E\u0441\u043B\u043E: "osl",
|
||||||
|
\u043A\u043E\u043F\u0435\u043D\u0433\u0430\u0433\u0435\u043D: "cph",
|
||||||
|
\u0446\u044E\u0440\u0438\u0445: "zrh",
|
||||||
|
\u0431\u0440\u044E\u0441\u0441\u0435\u043B\u044C: "bru",
|
||||||
|
\u043C\u0430\u0434\u0440\u0438\u0434: "mad",
|
||||||
|
\u0431\u0430\u0440\u0441\u0435\u043B\u043E\u043D\u0430: "bcn",
|
||||||
|
\u043C\u0438\u043B\u0430\u043D: "mxp",
|
||||||
|
\u0440\u0438\u043C: "rom"
|
||||||
|
};
|
||||||
|
function locCodeForCity(city, usedCodes) {
|
||||||
|
const known = CANONICAL_LOC_CODES[city.name.trim().toLowerCase()];
|
||||||
|
if (known && !usedCodes.has(known)) return known;
|
||||||
|
if (known) {
|
||||||
|
}
|
||||||
|
const base = city.nameEn.toLowerCase().replace(/[^a-z]/g, "");
|
||||||
|
if (!base) {
|
||||||
|
return `${city.countryCode.toLowerCase()}${usedCodes.size}`;
|
||||||
|
}
|
||||||
|
for (let len = 3; len <= Math.max(3, base.length); len++) {
|
||||||
|
const candidate = base.slice(0, len);
|
||||||
|
if (!usedCodes.has(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
let i = 2;
|
||||||
|
while (usedCodes.has(`${base.slice(0, 2)}${i}`)) i += 1;
|
||||||
|
return `${base.slice(0, 2)}${i}`;
|
||||||
|
}
|
||||||
|
function catalogCitiesWithLocCodes() {
|
||||||
|
const used = /* @__PURE__ */ new Set();
|
||||||
|
const ordered = [...CITIES].sort((a, b) => {
|
||||||
|
const ak = CANONICAL_LOC_CODES[a.name.toLowerCase()] ? 0 : 1;
|
||||||
|
const bk = CANONICAL_LOC_CODES[b.name.toLowerCase()] ? 0 : 1;
|
||||||
|
return ak - bk;
|
||||||
|
});
|
||||||
|
return ordered.map((city) => {
|
||||||
|
const locCode = locCodeForCity(city, used);
|
||||||
|
used.add(locCode);
|
||||||
|
return { ...city, locCode };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
COUNTRIES,
|
||||||
|
COUNTRY_BY_CODE,
|
||||||
|
COUNTRY_BY_NAME_RU,
|
||||||
|
CITIES,
|
||||||
|
CITY_BY_NAME_RU,
|
||||||
|
resolveCityRef,
|
||||||
|
resolveCountryForCity,
|
||||||
|
cityMatchesCountry,
|
||||||
|
buildCityOptions,
|
||||||
|
resolveCountryForCityFromRows,
|
||||||
|
listCities,
|
||||||
|
countriesWithCities,
|
||||||
|
CANONICAL_LOC_CODES,
|
||||||
|
locCodeForCity,
|
||||||
|
catalogCitiesWithLocCodes
|
||||||
|
};
|
||||||
Vendored
+76
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* Стандартизированный справочник стран (ISO 3166-1 alpha-2) с русскими названиями.
|
||||||
|
* Источник: общедоступный справочник. Не зависит от данных в БД vps-tracker.
|
||||||
|
*/
|
||||||
|
interface CountryRef {
|
||||||
|
/** ISO 3166-1 alpha-2 код */
|
||||||
|
code: string;
|
||||||
|
/** Русское название */
|
||||||
|
name: string;
|
||||||
|
/** Английское название */
|
||||||
|
nameEn: string;
|
||||||
|
}
|
||||||
|
declare const COUNTRIES: readonly CountryRef[];
|
||||||
|
declare const COUNTRY_BY_CODE: Record<string, CountryRef>;
|
||||||
|
declare const COUNTRY_BY_NAME_RU: Record<string, CountryRef>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Стандартизированный справочник городов по странам.
|
||||||
|
* Используется в фильтрах VPS и форме редактирования.
|
||||||
|
* Не зависит от данных в БД vps-tracker — общедоступный статичный справочник.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface CityRef {
|
||||||
|
/** ISO 3166-1 alpha-2 код страны */
|
||||||
|
countryCode: string;
|
||||||
|
/** Русское название города */
|
||||||
|
name: string;
|
||||||
|
/** Английское название города */
|
||||||
|
nameEn: string;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Топ-города по странам (включая города с крупными дата-центрами).
|
||||||
|
* Список намеренно ограничен — расширяется по мере необходимости.
|
||||||
|
*/
|
||||||
|
declare const CITIES: readonly CityRef[];
|
||||||
|
declare const CITY_BY_NAME_RU: Record<string, CityRef>;
|
||||||
|
/** Найти город в справочнике по русскому названию. */
|
||||||
|
declare function resolveCityRef(cityName: string): CityRef | undefined;
|
||||||
|
/** Страна из справочника для города (если есть привязка). */
|
||||||
|
declare function resolveCountryForCity(cityName: string): CountryRef | undefined;
|
||||||
|
interface CityLocationRow {
|
||||||
|
city?: string | null;
|
||||||
|
country?: string | null;
|
||||||
|
}
|
||||||
|
/** Проверить, относится ли город к выбранной стране (справочник или данные VPS). */
|
||||||
|
declare function cityMatchesCountry(cityName: string, countryName: string, rows?: readonly CityLocationRow[]): boolean;
|
||||||
|
/** Опции городов: из VPS и опционально справочника, опционально по стране. */
|
||||||
|
declare function buildCityOptions(rows: readonly CityLocationRow[] | undefined, countryName?: string, options?: {
|
||||||
|
includeCatalog?: boolean;
|
||||||
|
}): {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}[];
|
||||||
|
/** Страна для города: справочник, иначе первая запись VPS с таким городом. */
|
||||||
|
declare function resolveCountryForCityFromRows(cityName: string, rows: readonly CityLocationRow[] | undefined): string | undefined;
|
||||||
|
interface CityOption extends CityRef {
|
||||||
|
country: CountryRef;
|
||||||
|
}
|
||||||
|
/** Список городов с привязкой к стране. */
|
||||||
|
declare function listCities(countryCode?: string): CityOption[];
|
||||||
|
/** Список стран, у которых есть города в справочнике. */
|
||||||
|
declare function countriesWithCities(): CountryRef[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Канонические коды {loc} для hostname (из naming seed / docs).
|
||||||
|
* Ключ — русское имя города (lower case).
|
||||||
|
*/
|
||||||
|
declare const CANONICAL_LOC_CODES: Record<string, string>;
|
||||||
|
/** Детерминированный уникальный loc-код для города (hostname {loc}). */
|
||||||
|
declare function locCodeForCity(city: Pick<CityRef, 'name' | 'nameEn' | 'countryCode'>, usedCodes: ReadonlySet<string>): string;
|
||||||
|
/** Все города каталога с выделенными loc-кодами (без коллизий). */
|
||||||
|
declare function catalogCitiesWithLocCodes(): Array<CityRef & {
|
||||||
|
locCode: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export { CANONICAL_LOC_CODES, CITIES, CITY_BY_NAME_RU, COUNTRIES, COUNTRY_BY_CODE, COUNTRY_BY_NAME_RU, type CityLocationRow, type CityOption, type CityRef, type CountryRef, buildCityOptions, catalogCitiesWithLocCodes, cityMatchesCountry, countriesWithCities, listCities, locCodeForCity, resolveCityRef, resolveCountryForCity, resolveCountryForCityFromRows };
|
||||||
Vendored
+34
@@ -0,0 +1,34 @@
|
|||||||
|
import {
|
||||||
|
CANONICAL_LOC_CODES,
|
||||||
|
CITIES,
|
||||||
|
CITY_BY_NAME_RU,
|
||||||
|
COUNTRIES,
|
||||||
|
COUNTRY_BY_CODE,
|
||||||
|
COUNTRY_BY_NAME_RU,
|
||||||
|
buildCityOptions,
|
||||||
|
catalogCitiesWithLocCodes,
|
||||||
|
cityMatchesCountry,
|
||||||
|
countriesWithCities,
|
||||||
|
listCities,
|
||||||
|
locCodeForCity,
|
||||||
|
resolveCityRef,
|
||||||
|
resolveCountryForCity,
|
||||||
|
resolveCountryForCityFromRows
|
||||||
|
} from "../chunk-FK3U7DSV.js";
|
||||||
|
export {
|
||||||
|
CANONICAL_LOC_CODES,
|
||||||
|
CITIES,
|
||||||
|
CITY_BY_NAME_RU,
|
||||||
|
COUNTRIES,
|
||||||
|
COUNTRY_BY_CODE,
|
||||||
|
COUNTRY_BY_NAME_RU,
|
||||||
|
buildCityOptions,
|
||||||
|
catalogCitiesWithLocCodes,
|
||||||
|
cityMatchesCountry,
|
||||||
|
countriesWithCities,
|
||||||
|
listCities,
|
||||||
|
locCodeForCity,
|
||||||
|
resolveCityRef,
|
||||||
|
resolveCountryForCity,
|
||||||
|
resolveCountryForCityFromRows
|
||||||
|
};
|
||||||
Vendored
+1
@@ -1,4 +1,5 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
export { CANONICAL_LOC_CODES, CITIES, CITY_BY_NAME_RU, COUNTRIES, COUNTRY_BY_CODE, COUNTRY_BY_NAME_RU, CityLocationRow, CityOption, CityRef, CountryRef, buildCityOptions, catalogCitiesWithLocCodes, cityMatchesCountry, countriesWithCities, listCities, locCodeForCity, resolveCityRef, resolveCountryForCity, resolveCountryForCityFromRows } from './geo/index.js';
|
||||||
|
|
||||||
declare const appSwitcherIconSchema: z.ZodEnum<{
|
declare const appSwitcherIconSchema: z.ZodEnum<{
|
||||||
server: "server";
|
server: "server";
|
||||||
|
|||||||
Vendored
+33
@@ -1,3 +1,21 @@
|
|||||||
|
import {
|
||||||
|
CANONICAL_LOC_CODES,
|
||||||
|
CITIES,
|
||||||
|
CITY_BY_NAME_RU,
|
||||||
|
COUNTRIES,
|
||||||
|
COUNTRY_BY_CODE,
|
||||||
|
COUNTRY_BY_NAME_RU,
|
||||||
|
buildCityOptions,
|
||||||
|
catalogCitiesWithLocCodes,
|
||||||
|
cityMatchesCountry,
|
||||||
|
countriesWithCities,
|
||||||
|
listCities,
|
||||||
|
locCodeForCity,
|
||||||
|
resolveCityRef,
|
||||||
|
resolveCountryForCity,
|
||||||
|
resolveCountryForCityFromRows
|
||||||
|
} from "./chunk-FK3U7DSV.js";
|
||||||
|
|
||||||
// src/app-switcher.ts
|
// src/app-switcher.ts
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
var appSwitcherIconSchema = z.enum([
|
var appSwitcherIconSchema = z.enum([
|
||||||
@@ -265,6 +283,12 @@ var ValidationError = class extends Error {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
export {
|
export {
|
||||||
|
CANONICAL_LOC_CODES,
|
||||||
|
CITIES,
|
||||||
|
CITY_BY_NAME_RU,
|
||||||
|
COUNTRIES,
|
||||||
|
COUNTRY_BY_CODE,
|
||||||
|
COUNTRY_BY_NAME_RU,
|
||||||
ValidationError,
|
ValidationError,
|
||||||
addressFamilySchema,
|
addressFamilySchema,
|
||||||
aliasCreateSchema,
|
aliasCreateSchema,
|
||||||
@@ -279,10 +303,16 @@ export {
|
|||||||
appSwitcherEntrySchema,
|
appSwitcherEntrySchema,
|
||||||
appSwitcherIconSchema,
|
appSwitcherIconSchema,
|
||||||
bindExportSchema,
|
bindExportSchema,
|
||||||
|
buildCityOptions,
|
||||||
|
catalogCitiesWithLocCodes,
|
||||||
cfDnsRecordSchema,
|
cfDnsRecordSchema,
|
||||||
cfZoneSchema,
|
cfZoneSchema,
|
||||||
|
cityMatchesCountry,
|
||||||
|
countriesWithCities,
|
||||||
createDnsRecordPayloadSchema,
|
createDnsRecordPayloadSchema,
|
||||||
dashboardStatsSchema,
|
dashboardStatsSchema,
|
||||||
|
listCities,
|
||||||
|
locCodeForCity,
|
||||||
locationSchema,
|
locationSchema,
|
||||||
loginSchema,
|
loginSchema,
|
||||||
nodeAddressSchema,
|
nodeAddressSchema,
|
||||||
@@ -292,6 +322,9 @@ export {
|
|||||||
nodeSchema,
|
nodeSchema,
|
||||||
orphanIgnoreSchema,
|
orphanIgnoreSchema,
|
||||||
patchDnsRecordPayloadSchema,
|
patchDnsRecordPayloadSchema,
|
||||||
|
resolveCityRef,
|
||||||
|
resolveCountryForCity,
|
||||||
|
resolveCountryForCityFromRows,
|
||||||
syncApplySchema,
|
syncApplySchema,
|
||||||
syncDiffOpSchema,
|
syncDiffOpSchema,
|
||||||
syncJobSchema,
|
syncJobSchema,
|
||||||
|
|||||||
@@ -12,10 +12,15 @@
|
|||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"development": "./src/index.ts",
|
"development": "./src/index.ts",
|
||||||
"import": "./dist/index.js"
|
"import": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./geo": {
|
||||||
|
"types": "./dist/geo/index.d.ts",
|
||||||
|
"development": "./src/geo/index.ts",
|
||||||
|
"import": "./dist/geo/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup src/index.ts --format esm --dts",
|
"build": "tsup src/index.ts src/geo/index.ts --format esm --dts",
|
||||||
"dev": "tsup src/index.ts --format esm --dts --watch",
|
"dev": "tsup src/index.ts --format esm --dts --watch",
|
||||||
"test": "vitest run --passWithNoTests"
|
"test": "vitest run --passWithNoTests"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
/**
|
||||||
|
* Стандартизированный справочник городов по странам.
|
||||||
|
* Используется в фильтрах VPS и форме редактирования.
|
||||||
|
* Не зависит от данных в БД vps-tracker — общедоступный статичный справочник.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { COUNTRY_BY_CODE, COUNTRY_BY_NAME_RU, COUNTRIES, type CountryRef } from './countries.js'
|
||||||
|
|
||||||
|
export interface CityRef {
|
||||||
|
/** ISO 3166-1 alpha-2 код страны */
|
||||||
|
countryCode: string
|
||||||
|
/** Русское название города */
|
||||||
|
name: string
|
||||||
|
/** Английское название города */
|
||||||
|
nameEn: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Топ-города по странам (включая города с крупными дата-центрами).
|
||||||
|
* Список намеренно ограничен — расширяется по мере необходимости.
|
||||||
|
*/
|
||||||
|
export const CITIES: readonly CityRef[] = [
|
||||||
|
// Россия
|
||||||
|
{ countryCode: 'RU', name: 'Москва', nameEn: 'Moscow' },
|
||||||
|
{ countryCode: 'RU', name: 'Санкт-Петербург', nameEn: 'Saint Petersburg' },
|
||||||
|
{ countryCode: 'RU', name: 'Новосибирск', nameEn: 'Novosibirsk' },
|
||||||
|
{ countryCode: 'RU', name: 'Екатеринбург', nameEn: 'Yekaterinburg' },
|
||||||
|
{ countryCode: 'RU', name: 'Казань', nameEn: 'Kazan' },
|
||||||
|
{ countryCode: 'RU', name: 'Нижний Новгород', nameEn: 'Nizhny Novgorod' },
|
||||||
|
{ countryCode: 'RU', name: 'Самара', nameEn: 'Samara' },
|
||||||
|
{ countryCode: 'RU', name: 'Ростов-на-Дону', nameEn: 'Rostov-on-Don' },
|
||||||
|
{ countryCode: 'RU', name: 'Уфа', nameEn: 'Ufa' },
|
||||||
|
{ countryCode: 'RU', name: 'Краснодар', nameEn: 'Krasnodar' },
|
||||||
|
{ countryCode: 'RU', name: 'Сочи', nameEn: 'Sochi' },
|
||||||
|
{ countryCode: 'RU', name: 'Владивосток', nameEn: 'Vladivostok' },
|
||||||
|
{ countryCode: 'RU', name: 'Иркутск', nameEn: 'Irkutsk' },
|
||||||
|
{ countryCode: 'RU', name: 'Хабаровск', nameEn: 'Khabarovsk' },
|
||||||
|
{ countryCode: 'RU', name: 'Тюмень', nameEn: 'Tyumen' },
|
||||||
|
// Нидерланды
|
||||||
|
{ countryCode: 'NL', name: 'Амстердам', nameEn: 'Amsterdam' },
|
||||||
|
{ countryCode: 'NL', name: 'Роттердам', nameEn: 'Rotterdam' },
|
||||||
|
{ countryCode: 'NL', name: 'Гаага', nameEn: 'The Hague' },
|
||||||
|
// Германия
|
||||||
|
{ countryCode: 'DE', name: 'Франкфурт', nameEn: 'Frankfurt' },
|
||||||
|
{ countryCode: 'DE', name: 'Берлин', nameEn: 'Berlin' },
|
||||||
|
{ countryCode: 'DE', name: 'Мюнхен', nameEn: 'Munich' },
|
||||||
|
{ countryCode: 'DE', name: 'Гамбург', nameEn: 'Hamburg' },
|
||||||
|
{ countryCode: 'DE', name: 'Кёльн', nameEn: 'Cologne' },
|
||||||
|
{ countryCode: 'DE', name: 'Дюссельдорф', nameEn: 'Düsseldorf' },
|
||||||
|
{ countryCode: 'DE', name: 'Штутгарт', nameEn: 'Stuttgart' },
|
||||||
|
// Финляндия
|
||||||
|
{ countryCode: 'FI', name: 'Хельсинки', nameEn: 'Helsinki' },
|
||||||
|
{ countryCode: 'FI', name: 'Эспоо', nameEn: 'Espoo' },
|
||||||
|
{ countryCode: 'FI', name: 'Тампере', nameEn: 'Tampere' },
|
||||||
|
// Швеция
|
||||||
|
{ countryCode: 'SE', name: 'Стокгольм', nameEn: 'Stockholm' },
|
||||||
|
{ countryCode: 'SE', name: 'Гётеборг', nameEn: 'Gothenburg' },
|
||||||
|
{ countryCode: 'SE', name: 'Мальмё', nameEn: 'Malmö' },
|
||||||
|
// Норвегия
|
||||||
|
{ countryCode: 'NO', name: 'Осло', nameEn: 'Oslo' },
|
||||||
|
{ countryCode: 'NO', name: 'Берген', nameEn: 'Bergen' },
|
||||||
|
// Дания
|
||||||
|
{ countryCode: 'DK', name: 'Копенгаген', nameEn: 'Copenhagen' },
|
||||||
|
{ countryCode: 'DK', name: 'Орхус', nameEn: 'Aarhus' },
|
||||||
|
// Великобритания
|
||||||
|
{ countryCode: 'GB', name: 'Лондон', nameEn: 'London' },
|
||||||
|
{ countryCode: 'GB', name: 'Манчестер', nameEn: 'Manchester' },
|
||||||
|
{ countryCode: 'GB', name: 'Глазго', nameEn: 'Glasgow' },
|
||||||
|
{ countryCode: 'GB', name: 'Кардифф', nameEn: 'Cardiff' },
|
||||||
|
// Франция
|
||||||
|
{ countryCode: 'FR', name: 'Париж', nameEn: 'Paris' },
|
||||||
|
{ countryCode: 'FR', name: 'Марсель', nameEn: 'Marseille' },
|
||||||
|
{ countryCode: 'FR', name: 'Лион', nameEn: 'Lyon' },
|
||||||
|
{ countryCode: 'FR', name: 'Страсбург', nameEn: 'Strasbourg' },
|
||||||
|
// Испания
|
||||||
|
{ countryCode: 'ES', name: 'Мадрид', nameEn: 'Madrid' },
|
||||||
|
{ countryCode: 'ES', name: 'Барселона', nameEn: 'Barcelona' },
|
||||||
|
{ countryCode: 'ES', name: 'Валенсия', nameEn: 'Valencia' },
|
||||||
|
// Италия
|
||||||
|
{ countryCode: 'IT', name: 'Рим', nameEn: 'Rome' },
|
||||||
|
{ countryCode: 'IT', name: 'Милан', nameEn: 'Milan' },
|
||||||
|
{ countryCode: 'IT', name: 'Турин', nameEn: 'Turin' },
|
||||||
|
// Чехия
|
||||||
|
{ countryCode: 'CZ', name: 'Прага', nameEn: 'Prague' },
|
||||||
|
{ countryCode: 'CZ', name: 'Брно', nameEn: 'Brno' },
|
||||||
|
// Польша
|
||||||
|
{ countryCode: 'PL', name: 'Варшава', nameEn: 'Warsaw' },
|
||||||
|
{ countryCode: 'PL', name: 'Краков', nameEn: 'Kraków' },
|
||||||
|
{ countryCode: 'PL', name: 'Вроцлав', nameEn: 'Wrocław' },
|
||||||
|
{ countryCode: 'PL', name: 'Гданьск', nameEn: 'Gdańsk' },
|
||||||
|
// Швейцария
|
||||||
|
{ countryCode: 'CH', name: 'Цюрих', nameEn: 'Zurich' },
|
||||||
|
{ countryCode: 'CH', name: 'Женева', nameEn: 'Geneva' },
|
||||||
|
{ countryCode: 'CH', name: 'Базель', nameEn: 'Basel' },
|
||||||
|
// Бельгия
|
||||||
|
{ countryCode: 'BE', name: 'Брюссель', nameEn: 'Brussels' },
|
||||||
|
{ countryCode: 'BE', name: 'Антверпен', nameEn: 'Antwerp' },
|
||||||
|
// Австрия
|
||||||
|
{ countryCode: 'AT', name: 'Вена', nameEn: 'Vienna' },
|
||||||
|
{ countryCode: 'AT', name: 'Грац', nameEn: 'Graz' },
|
||||||
|
// Латвия
|
||||||
|
{ countryCode: 'LV', name: 'Рига', nameEn: 'Riga' },
|
||||||
|
// Литва
|
||||||
|
{ countryCode: 'LT', name: 'Вильнюс', nameEn: 'Vilnius' },
|
||||||
|
{ countryCode: 'LT', name: 'Каунас', nameEn: 'Kaunas' },
|
||||||
|
// Эстония
|
||||||
|
{ countryCode: 'EE', name: 'Таллин', nameEn: 'Tallinn' },
|
||||||
|
// Украина
|
||||||
|
{ countryCode: 'UA', name: 'Киев', nameEn: 'Kyiv' },
|
||||||
|
{ countryCode: 'UA', name: 'Харьков', nameEn: 'Kharkiv' },
|
||||||
|
{ countryCode: 'UA', name: 'Одесса', nameEn: 'Odesa' },
|
||||||
|
{ countryCode: 'UA', name: 'Львов', nameEn: 'Lviv' },
|
||||||
|
// Казахстан
|
||||||
|
{ countryCode: 'KZ', name: 'Алматы', nameEn: 'Almaty' },
|
||||||
|
{ countryCode: 'KZ', name: 'Астана', nameEn: 'Astana' },
|
||||||
|
// Беларусь
|
||||||
|
{ countryCode: 'BY', name: 'Минск', nameEn: 'Minsk' },
|
||||||
|
// США
|
||||||
|
{ countryCode: 'US', name: 'Нью-Йорк', nameEn: 'New York' },
|
||||||
|
{ countryCode: 'US', name: 'Лос-Анджелес', nameEn: 'Los Angeles' },
|
||||||
|
{ countryCode: 'US', name: 'Чикаго', nameEn: 'Chicago' },
|
||||||
|
{ countryCode: 'US', name: 'Хьюстон', nameEn: 'Houston' },
|
||||||
|
{ countryCode: 'US', name: 'Даллас', nameEn: 'Dallas' },
|
||||||
|
{ countryCode: 'US', name: 'Майами', nameEn: 'Miami' },
|
||||||
|
{ countryCode: 'US', name: 'Сиэтл', nameEn: 'Seattle' },
|
||||||
|
{ countryCode: 'US', name: 'Сан-Франциско', nameEn: 'San Francisco' },
|
||||||
|
{ countryCode: 'US', name: 'Вашингтон', nameEn: 'Washington' },
|
||||||
|
{ countryCode: 'US', name: 'Атланта', nameEn: 'Atlanta' },
|
||||||
|
{ countryCode: 'US', name: 'Денвер', nameEn: 'Denver' },
|
||||||
|
// Канада
|
||||||
|
{ countryCode: 'CA', name: 'Торонто', nameEn: 'Toronto' },
|
||||||
|
{ countryCode: 'CA', name: 'Монреаль', nameEn: 'Montreal' },
|
||||||
|
{ countryCode: 'CA', name: 'Ванкувер', nameEn: 'Vancouver' },
|
||||||
|
// Сингапур
|
||||||
|
{ countryCode: 'SG', name: 'Сингапур', nameEn: 'Singapore' },
|
||||||
|
// Япония
|
||||||
|
{ countryCode: 'JP', name: 'Токио', nameEn: 'Tokyo' },
|
||||||
|
{ countryCode: 'JP', name: 'Осака', nameEn: 'Osaka' },
|
||||||
|
// Южная Корея
|
||||||
|
{ countryCode: 'KR', name: 'Сеул', nameEn: 'Seoul' },
|
||||||
|
// Китай
|
||||||
|
{ countryCode: 'CN', name: 'Пекин', nameEn: 'Beijing' },
|
||||||
|
{ countryCode: 'CN', name: 'Шанхай', nameEn: 'Shanghai' },
|
||||||
|
{ countryCode: 'CN', name: 'Гуанчжоу', nameEn: 'Guangzhou' },
|
||||||
|
{ countryCode: 'CN', name: 'Шэньчжэнь', nameEn: 'Shenzhen' },
|
||||||
|
{ countryCode: 'CN', name: 'Гонконг', nameEn: 'Hong Kong' },
|
||||||
|
// Индия
|
||||||
|
{ countryCode: 'IN', name: 'Мумбаи', nameEn: 'Mumbai' },
|
||||||
|
{ countryCode: 'IN', name: 'Дели', nameEn: 'Delhi' },
|
||||||
|
{ countryCode: 'IN', name: 'Бангалор', nameEn: 'Bangalore' },
|
||||||
|
{ countryCode: 'IN', name: 'Ченнаи', nameEn: 'Chennai' },
|
||||||
|
// ОАЭ
|
||||||
|
{ countryCode: 'AE', name: 'Дубай', nameEn: 'Dubai' },
|
||||||
|
{ countryCode: 'AE', name: 'Абу-Даби', nameEn: 'Abu Dhabi' },
|
||||||
|
// Турция
|
||||||
|
{ countryCode: 'TR', name: 'Стамбул', nameEn: 'Istanbul' },
|
||||||
|
{ countryCode: 'TR', name: 'Анкара', nameEn: 'Ankara' },
|
||||||
|
{ countryCode: 'TR', name: 'Измир', nameEn: 'Izmir' },
|
||||||
|
// Бразилия
|
||||||
|
{ countryCode: 'BR', name: 'Сан-Паулу', nameEn: 'São Paulo' },
|
||||||
|
{ countryCode: 'BR', name: 'Рио-де-Жанейро', nameEn: 'Rio de Janeiro' },
|
||||||
|
// Австралия
|
||||||
|
{ countryCode: 'AU', name: 'Сидней', nameEn: 'Sydney' },
|
||||||
|
{ countryCode: 'AU', name: 'Мельбурн', nameEn: 'Melbourne' },
|
||||||
|
// Болгария
|
||||||
|
{ countryCode: 'BG', name: 'София', nameEn: 'Sofia' },
|
||||||
|
// Румыния
|
||||||
|
{ countryCode: 'RO', name: 'Бухарест', nameEn: 'Bucharest' },
|
||||||
|
// Венгрия
|
||||||
|
{ countryCode: 'HU', name: 'Будапешт', nameEn: 'Budapest' },
|
||||||
|
// Греция
|
||||||
|
{ countryCode: 'GR', name: 'Афины', nameEn: 'Athens' },
|
||||||
|
// Португалия
|
||||||
|
{ countryCode: 'PT', name: 'Лиссабон', nameEn: 'Lisbon' },
|
||||||
|
// Ирландия
|
||||||
|
{ countryCode: 'IE', name: 'Дублин', nameEn: 'Dublin' },
|
||||||
|
// Израиль
|
||||||
|
{ countryCode: 'IL', name: 'Тель-Авив', nameEn: 'Tel Aviv' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const CITY_BY_NAME_RU: Record<string, CityRef> = Object.fromEntries(
|
||||||
|
CITIES.map((c) => [c.name.toLowerCase(), c]),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Найти город в справочнике по русскому названию. */
|
||||||
|
export function resolveCityRef(cityName: string): CityRef | undefined {
|
||||||
|
return CITY_BY_NAME_RU[cityName.trim().toLowerCase()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Страна из справочника для города (если есть привязка). */
|
||||||
|
export function resolveCountryForCity(cityName: string): CountryRef | undefined {
|
||||||
|
const city = resolveCityRef(cityName)
|
||||||
|
return city ? COUNTRY_BY_CODE[city.countryCode] : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CityLocationRow {
|
||||||
|
city?: string | null
|
||||||
|
country?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Проверить, относится ли город к выбранной стране (справочник или данные VPS). */
|
||||||
|
export function cityMatchesCountry(
|
||||||
|
cityName: string,
|
||||||
|
countryName: string,
|
||||||
|
rows?: readonly CityLocationRow[],
|
||||||
|
): boolean {
|
||||||
|
const countryCode = COUNTRY_BY_NAME_RU[countryName.trim().toLowerCase()]?.code
|
||||||
|
if (!countryCode) return true
|
||||||
|
|
||||||
|
const catalog = resolveCityRef(cityName)
|
||||||
|
if (catalog) return catalog.countryCode === countryCode
|
||||||
|
|
||||||
|
return (rows ?? []).some(
|
||||||
|
(row) =>
|
||||||
|
row.city?.trim().toLowerCase() === cityName.trim().toLowerCase() &&
|
||||||
|
COUNTRY_BY_NAME_RU[(row.country || '').trim().toLowerCase()]?.code === countryCode,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Опции городов: из VPS и опционально справочника, опционально по стране. */
|
||||||
|
export function buildCityOptions(
|
||||||
|
rows: readonly CityLocationRow[] | undefined,
|
||||||
|
countryName?: string,
|
||||||
|
options?: { includeCatalog?: boolean },
|
||||||
|
): { value: string; label: string }[] {
|
||||||
|
const includeCatalog = options?.includeCatalog ?? true
|
||||||
|
const names = new Set<string>()
|
||||||
|
const countryCode = countryName?.trim()
|
||||||
|
? COUNTRY_BY_NAME_RU[countryName.trim().toLowerCase()]?.code
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const belongsToCountry = (city: string, rowCountry?: string): boolean => {
|
||||||
|
if (!countryCode) return true
|
||||||
|
const catalog = resolveCityRef(city)
|
||||||
|
if (catalog) return catalog.countryCode === countryCode
|
||||||
|
if (rowCountry?.trim()) {
|
||||||
|
return COUNTRY_BY_NAME_RU[rowCountry.trim().toLowerCase()]?.code === countryCode
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of rows ?? []) {
|
||||||
|
const city = (row.city || '').trim()
|
||||||
|
if (!city) continue
|
||||||
|
if (belongsToCountry(city, row.country ?? undefined)) {
|
||||||
|
names.add(city)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includeCatalog) {
|
||||||
|
for (const { name } of listCities(countryCode)) {
|
||||||
|
names.add(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...names]
|
||||||
|
.sort((a, b) => a.localeCompare(b, 'ru'))
|
||||||
|
.map((name) => ({ value: name, label: name }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Страна для города: справочник, иначе первая запись VPS с таким городом. */
|
||||||
|
export function resolveCountryForCityFromRows(
|
||||||
|
cityName: string,
|
||||||
|
rows: readonly CityLocationRow[] | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
const trimmed = cityName.trim()
|
||||||
|
if (!trimmed) return undefined
|
||||||
|
|
||||||
|
const fromCatalog = resolveCountryForCity(trimmed)
|
||||||
|
if (fromCatalog) return fromCatalog.name
|
||||||
|
|
||||||
|
const row = (rows ?? []).find(
|
||||||
|
(r) => r.city?.trim().toLowerCase() === trimmed.toLowerCase() && r.country?.trim(),
|
||||||
|
)
|
||||||
|
return row?.country?.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CityOption extends CityRef {
|
||||||
|
country: CountryRef
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Список городов с привязкой к стране. */
|
||||||
|
export function listCities(countryCode?: string): CityOption[] {
|
||||||
|
return CITIES.filter((c) => !countryCode || c.countryCode === countryCode).map((c) => ({
|
||||||
|
...c,
|
||||||
|
country: COUNTRY_BY_CODE[c.countryCode],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Список стран, у которых есть города в справочнике. */
|
||||||
|
export function countriesWithCities(): CountryRef[] {
|
||||||
|
const codes = new Set(CITIES.map((c) => c.countryCode))
|
||||||
|
return COUNTRIES.filter((c) => codes.has(c.code))
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* Стандартизированный справочник стран (ISO 3166-1 alpha-2) с русскими названиями.
|
||||||
|
* Источник: общедоступный справочник. Не зависит от данных в БД vps-tracker.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface CountryRef {
|
||||||
|
/** ISO 3166-1 alpha-2 код */
|
||||||
|
code: string
|
||||||
|
/** Русское название */
|
||||||
|
name: string
|
||||||
|
/** Английское название */
|
||||||
|
nameEn: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const COUNTRIES: readonly CountryRef[] = [
|
||||||
|
{ code: 'AU', name: 'Австралия', nameEn: 'Australia' },
|
||||||
|
{ code: 'AT', name: 'Австрия', nameEn: 'Austria' },
|
||||||
|
{ code: 'AZ', name: 'Азербайджан', nameEn: 'Azerbaijan' },
|
||||||
|
{ code: 'AL', name: 'Албания', nameEn: 'Albania' },
|
||||||
|
{ code: 'DZ', name: 'Алжир', nameEn: 'Algeria' },
|
||||||
|
{ code: 'AO', name: 'Ангола', nameEn: 'Angola' },
|
||||||
|
{ code: 'AR', name: 'Аргентина', nameEn: 'Argentina' },
|
||||||
|
{ code: 'AM', name: 'Армения', nameEn: 'Armenia' },
|
||||||
|
{ code: 'AF', name: 'Афганистан', nameEn: 'Afghanistan' },
|
||||||
|
{ code: 'BE', name: 'Бельгия', nameEn: 'Belgium' },
|
||||||
|
{ code: 'BG', name: 'Болгария', nameEn: 'Bulgaria' },
|
||||||
|
{ code: 'BO', name: 'Боливия', nameEn: 'Bolivia' },
|
||||||
|
{ code: 'BA', name: 'Босния и Герцеговина', nameEn: 'Bosnia and Herzegovina' },
|
||||||
|
{ code: 'BR', name: 'Бразилия', nameEn: 'Brazil' },
|
||||||
|
{ code: 'GB', name: 'Великобритания', nameEn: 'United Kingdom' },
|
||||||
|
{ code: 'HU', name: 'Венгрия', nameEn: 'Hungary' },
|
||||||
|
{ code: 'VE', name: 'Венесуэла', nameEn: 'Venezuela' },
|
||||||
|
{ code: 'VN', name: 'Вьетнам', nameEn: 'Vietnam' },
|
||||||
|
{ code: 'GA', name: 'Габон', nameEn: 'Gabon' },
|
||||||
|
{ code: 'HT', name: 'Гаити', nameEn: 'Haiti' },
|
||||||
|
{ code: 'GY', name: 'Гайана', nameEn: 'Guyana' },
|
||||||
|
{ code: 'GM', name: 'Гамбия', nameEn: 'Gambia' },
|
||||||
|
{ code: 'GH', name: 'Гана', nameEn: 'Ghana' },
|
||||||
|
{ code: 'GT', name: 'Гватемала', nameEn: 'Guatemala' },
|
||||||
|
{ code: 'GN', name: 'Гвинея', nameEn: 'Guinea' },
|
||||||
|
{ code: 'DE', name: 'Германия', nameEn: 'Germany' },
|
||||||
|
{ code: 'HN', name: 'Гондурас', nameEn: 'Honduras' },
|
||||||
|
{ code: 'GR', name: 'Греция', nameEn: 'Greece' },
|
||||||
|
{ code: 'GE', name: 'Грузия', nameEn: 'Georgia' },
|
||||||
|
{ code: 'DK', name: 'Дания', nameEn: 'Denmark' },
|
||||||
|
{ code: 'CD', name: 'ДР Конго', nameEn: 'DR Congo' },
|
||||||
|
{ code: 'EG', name: 'Египет', nameEn: 'Egypt' },
|
||||||
|
{ code: 'ZM', name: 'Замбия', nameEn: 'Zambia' },
|
||||||
|
{ code: 'ZW', name: 'Зимбабве', nameEn: 'Zimbabwe' },
|
||||||
|
{ code: 'IL', name: 'Израиль', nameEn: 'Israel' },
|
||||||
|
{ code: 'IN', name: 'Индия', nameEn: 'India' },
|
||||||
|
{ code: 'ID', name: 'Индонезия', nameEn: 'Indonesia' },
|
||||||
|
{ code: 'JO', name: 'Иордания', nameEn: 'Jordan' },
|
||||||
|
{ code: 'IQ', name: 'Ирак', nameEn: 'Iraq' },
|
||||||
|
{ code: 'IR', name: 'Иран', nameEn: 'Iran' },
|
||||||
|
{ code: 'IE', name: 'Ирландия', nameEn: 'Ireland' },
|
||||||
|
{ code: 'IS', name: 'Исландия', nameEn: 'Iceland' },
|
||||||
|
{ code: 'ES', name: 'Испания', nameEn: 'Spain' },
|
||||||
|
{ code: 'IT', name: 'Италия', nameEn: 'Italy' },
|
||||||
|
{ code: 'YE', name: 'Йемен', nameEn: 'Yemen' },
|
||||||
|
{ code: 'KZ', name: 'Казахстан', nameEn: 'Kazakhstan' },
|
||||||
|
{ code: 'KH', name: 'Камбоджа', nameEn: 'Cambodia' },
|
||||||
|
{ code: 'CM', name: 'Камерун', nameEn: 'Cameroon' },
|
||||||
|
{ code: 'CA', name: 'Канада', nameEn: 'Canada' },
|
||||||
|
{ code: 'QA', name: 'Катар', nameEn: 'Qatar' },
|
||||||
|
{ code: 'KE', name: 'Кения', nameEn: 'Kenya' },
|
||||||
|
{ code: 'CY', name: 'Кипр', nameEn: 'Cyprus' },
|
||||||
|
{ code: 'KG', name: 'Киргизия', nameEn: 'Kyrgyzstan' },
|
||||||
|
{ code: 'CN', name: 'Китай', nameEn: 'China' },
|
||||||
|
{ code: 'CO', name: 'Колумбия', nameEn: 'Colombia' },
|
||||||
|
{ code: 'CG', name: 'Конго', nameEn: 'Congo' },
|
||||||
|
{ code: 'KR', name: 'Южная Корея', nameEn: 'South Korea' },
|
||||||
|
{ code: 'KP', name: 'Северная Корея', nameEn: 'North Korea' },
|
||||||
|
{ code: 'XK', name: 'Косово', nameEn: 'Kosovo' },
|
||||||
|
{ code: 'CR', name: 'Коста-Рика', nameEn: 'Costa Rica' },
|
||||||
|
{ code: 'CI', name: "Кот-д'Ивуар", nameEn: "Côte d'Ivoire" },
|
||||||
|
{ code: 'CU', name: 'Куба', nameEn: 'Cuba' },
|
||||||
|
{ code: 'KW', name: 'Кувейт', nameEn: 'Kuwait' },
|
||||||
|
{ code: 'LA', name: 'Лаос', nameEn: 'Laos' },
|
||||||
|
{ code: 'LV', name: 'Латвия', nameEn: 'Latvia' },
|
||||||
|
{ code: 'LR', name: 'Либерия', nameEn: 'Liberia' },
|
||||||
|
{ code: 'LB', name: 'Ливан', nameEn: 'Lebanon' },
|
||||||
|
{ code: 'LY', name: 'Ливия', nameEn: 'Libya' },
|
||||||
|
{ code: 'LT', name: 'Литва', nameEn: 'Lithuania' },
|
||||||
|
{ code: 'LI', name: 'Лихтенштейн', nameEn: 'Liechtenstein' },
|
||||||
|
{ code: 'LU', name: 'Люксембург', nameEn: 'Luxembourg' },
|
||||||
|
{ code: 'MU', name: 'Маврикий', nameEn: 'Mauritius' },
|
||||||
|
{ code: 'MR', name: 'Мавритания', nameEn: 'Mauritania' },
|
||||||
|
{ code: 'MG', name: 'Мадагаскар', nameEn: 'Madagascar' },
|
||||||
|
{ code: 'MO', name: 'Макао', nameEn: 'Macao' },
|
||||||
|
{ code: 'MW', name: 'Малави', nameEn: 'Malawi' },
|
||||||
|
{ code: 'MY', name: 'Малайзия', nameEn: 'Malaysia' },
|
||||||
|
{ code: 'ML', name: 'Мали', nameEn: 'Mali' },
|
||||||
|
{ code: 'MT', name: 'Мальта', nameEn: 'Malta' },
|
||||||
|
{ code: 'MA', name: 'Марокко', nameEn: 'Morocco' },
|
||||||
|
{ code: 'MX', name: 'Мексика', nameEn: 'Mexico' },
|
||||||
|
{ code: 'MZ', name: 'Мозамбик', nameEn: 'Mozambique' },
|
||||||
|
{ code: 'MD', name: 'Молдавия', nameEn: 'Moldova' },
|
||||||
|
{ code: 'MC', name: 'Монако', nameEn: 'Monaco' },
|
||||||
|
{ code: 'MN', name: 'Монголия', nameEn: 'Mongolia' },
|
||||||
|
{ code: 'MM', name: 'Мьянма', nameEn: 'Myanmar' },
|
||||||
|
{ code: 'NA', name: 'Намибия', nameEn: 'Namibia' },
|
||||||
|
{ code: 'NP', name: 'Непал', nameEn: 'Nepal' },
|
||||||
|
{ code: 'NE', name: 'Нигер', nameEn: 'Niger' },
|
||||||
|
{ code: 'NG', name: 'Нигерия', nameEn: 'Nigeria' },
|
||||||
|
{ code: 'NL', name: 'Нидерланды', nameEn: 'Netherlands' },
|
||||||
|
{ code: 'NI', name: 'Никарагуа', nameEn: 'Nicaragua' },
|
||||||
|
{ code: 'NZ', name: 'Новая Зеландия', nameEn: 'New Zealand' },
|
||||||
|
{ code: 'NO', name: 'Норвегия', nameEn: 'Norway' },
|
||||||
|
{ code: 'AE', name: 'ОАЭ', nameEn: 'United Arab Emirates' },
|
||||||
|
{ code: 'OM', name: 'Оман', nameEn: 'Oman' },
|
||||||
|
{ code: 'PK', name: 'Пакистан', nameEn: 'Pakistan' },
|
||||||
|
{ code: 'PA', name: 'Панама', nameEn: 'Panama' },
|
||||||
|
{ code: 'PG', name: 'Папуа — Новая Гвинея', nameEn: 'Papua New Guinea' },
|
||||||
|
{ code: 'PY', name: 'Парагвай', nameEn: 'Paraguay' },
|
||||||
|
{ code: 'PE', name: 'Перу', nameEn: 'Peru' },
|
||||||
|
{ code: 'PL', name: 'Польша', nameEn: 'Poland' },
|
||||||
|
{ code: 'PT', name: 'Португалия', nameEn: 'Portugal' },
|
||||||
|
{ code: 'RU', name: 'Россия', nameEn: 'Russia' },
|
||||||
|
{ code: 'RW', name: 'Руанда', nameEn: 'Rwanda' },
|
||||||
|
{ code: 'RO', name: 'Румыния', nameEn: 'Romania' },
|
||||||
|
{ code: 'SV', name: 'Сальвадор', nameEn: 'El Salvador' },
|
||||||
|
{ code: 'SA', name: 'Саудовская Аравия', nameEn: 'Saudi Arabia' },
|
||||||
|
{ code: 'MK', name: 'Северная Македония', nameEn: 'North Macedonia' },
|
||||||
|
{ code: 'SC', name: 'Сейшелы', nameEn: 'Seychelles' },
|
||||||
|
{ code: 'SN', name: 'Сенегал', nameEn: 'Senegal' },
|
||||||
|
{ code: 'RS', name: 'Сербия', nameEn: 'Serbia' },
|
||||||
|
{ code: 'SG', name: 'Сингапур', nameEn: 'Singapore' },
|
||||||
|
{ code: 'SK', name: 'Словакия', nameEn: 'Slovakia' },
|
||||||
|
{ code: 'SI', name: 'Словения', nameEn: 'Slovenia' },
|
||||||
|
{ code: 'SO', name: 'Сомали', nameEn: 'Somalia' },
|
||||||
|
{ code: 'SD', name: 'Судан', nameEn: 'Sudan' },
|
||||||
|
{ code: 'US', name: 'США', nameEn: 'United States' },
|
||||||
|
{ code: 'TJ', name: 'Таджикистан', nameEn: 'Tajikistan' },
|
||||||
|
{ code: 'TH', name: 'Таиланд', nameEn: 'Thailand' },
|
||||||
|
{ code: 'TZ', name: 'Танзания', nameEn: 'Tanzania' },
|
||||||
|
{ code: 'TG', name: 'Того', nameEn: 'Togo' },
|
||||||
|
{ code: 'TT', name: 'Тринидад и Тобаго', nameEn: 'Trinidad and Tobago' },
|
||||||
|
{ code: 'TV', name: 'Тувалу', nameEn: 'Tuvalu' },
|
||||||
|
{ code: 'TN', name: 'Тунис', nameEn: 'Tunisia' },
|
||||||
|
{ code: 'TM', name: 'Туркмения', nameEn: 'Turkmenistan' },
|
||||||
|
{ code: 'TR', name: 'Турция', nameEn: 'Turkey' },
|
||||||
|
{ code: 'UG', name: 'Уганда', nameEn: 'Uganda' },
|
||||||
|
{ code: 'UZ', name: 'Узбекистан', nameEn: 'Uzbekistan' },
|
||||||
|
{ code: 'UA', name: 'Украина', nameEn: 'Ukraine' },
|
||||||
|
{ code: 'UY', name: 'Уругвай', nameEn: 'Uruguay' },
|
||||||
|
{ code: 'FJ', name: 'Фиджи', nameEn: 'Fiji' },
|
||||||
|
{ code: 'PH', name: 'Филиппины', nameEn: 'Philippines' },
|
||||||
|
{ code: 'FI', name: 'Финляндия', nameEn: 'Finland' },
|
||||||
|
{ code: 'FR', name: 'Франция', nameEn: 'France' },
|
||||||
|
{ code: 'HR', name: 'Хорватия', nameEn: 'Croatia' },
|
||||||
|
{ code: 'TD', name: 'Чад', nameEn: 'Chad' },
|
||||||
|
{ code: 'ME', name: 'Черногория', nameEn: 'Montenegro' },
|
||||||
|
{ code: 'CZ', name: 'Чехия', nameEn: 'Czechia' },
|
||||||
|
{ code: 'CL', name: 'Чили', nameEn: 'Chile' },
|
||||||
|
{ code: 'CH', name: 'Швейцария', nameEn: 'Switzerland' },
|
||||||
|
{ code: 'SE', name: 'Швеция', nameEn: 'Sweden' },
|
||||||
|
{ code: 'LK', name: 'Шри-Ланка', nameEn: 'Sri Lanka' },
|
||||||
|
{ code: 'EC', name: 'Эквадор', nameEn: 'Ecuador' },
|
||||||
|
{ code: 'GQ', name: 'Экваториальная Гвинея', nameEn: 'Equatorial Guinea' },
|
||||||
|
{ code: 'ER', name: 'Эритрея', nameEn: 'Eritrea' },
|
||||||
|
{ code: 'EE', name: 'Эстония', nameEn: 'Estonia' },
|
||||||
|
{ code: 'ET', name: 'Эфиопия', nameEn: 'Ethiopia' },
|
||||||
|
{ code: 'ZA', name: 'ЮАР', nameEn: 'South Africa' },
|
||||||
|
{ code: 'JM', name: 'Ямайка', nameEn: 'Jamaica' },
|
||||||
|
{ code: 'JP', name: 'Япония', nameEn: 'Japan' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const COUNTRY_BY_CODE: Record<string, CountryRef> = Object.fromEntries(
|
||||||
|
COUNTRIES.map((c) => [c.code, c]),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const COUNTRY_BY_NAME_RU: Record<string, CountryRef> = Object.fromEntries(
|
||||||
|
COUNTRIES.map((c) => [c.name.toLowerCase(), c]),
|
||||||
|
)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './countries.js'
|
||||||
|
export * from './cities.js'
|
||||||
|
export * from './loc-codes.js'
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { CITIES, type CityRef } from './cities.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Канонические коды {loc} для hostname (из naming seed / docs).
|
||||||
|
* Ключ — русское имя города (lower case).
|
||||||
|
*/
|
||||||
|
export const CANONICAL_LOC_CODES: Record<string, string> = {
|
||||||
|
москва: 'msk',
|
||||||
|
франкфурт: 'fra',
|
||||||
|
амстердам: 'ams',
|
||||||
|
хельсинки: 'hel',
|
||||||
|
париж: 'par',
|
||||||
|
'санкт-петербург': 'led',
|
||||||
|
берлин: 'ber',
|
||||||
|
мюнхен: 'muc',
|
||||||
|
лондон: 'lon',
|
||||||
|
'нью-йорк': 'nyc',
|
||||||
|
токио: 'tyo',
|
||||||
|
сингапур: 'sin',
|
||||||
|
дубай: 'dxb',
|
||||||
|
варшава: 'waw',
|
||||||
|
прага: 'prg',
|
||||||
|
вена: 'vie',
|
||||||
|
рига: 'rix',
|
||||||
|
таллин: 'tll',
|
||||||
|
минск: 'msq',
|
||||||
|
киев: 'iev',
|
||||||
|
стокгольм: 'arn',
|
||||||
|
осло: 'osl',
|
||||||
|
копенгаген: 'cph',
|
||||||
|
цюрих: 'zrh',
|
||||||
|
брюссель: 'bru',
|
||||||
|
мадрид: 'mad',
|
||||||
|
барселона: 'bcn',
|
||||||
|
милан: 'mxp',
|
||||||
|
рим: 'rom',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Детерминированный уникальный loc-код для города (hostname {loc}). */
|
||||||
|
export function locCodeForCity(
|
||||||
|
city: Pick<CityRef, 'name' | 'nameEn' | 'countryCode'>,
|
||||||
|
usedCodes: ReadonlySet<string>,
|
||||||
|
): string {
|
||||||
|
const known = CANONICAL_LOC_CODES[city.name.trim().toLowerCase()]
|
||||||
|
if (known && !usedCodes.has(known)) return known
|
||||||
|
if (known) {
|
||||||
|
// known занят другим — fallback ниже
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = city.nameEn.toLowerCase().replace(/[^a-z]/g, '')
|
||||||
|
if (!base) {
|
||||||
|
return `${city.countryCode.toLowerCase()}${usedCodes.size}`
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let len = 3; len <= Math.max(3, base.length); len++) {
|
||||||
|
const candidate = base.slice(0, len)
|
||||||
|
if (!usedCodes.has(candidate)) return candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
let i = 2
|
||||||
|
while (usedCodes.has(`${base.slice(0, 2)}${i}`)) i += 1
|
||||||
|
return `${base.slice(0, 2)}${i}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Все города каталога с выделенными loc-кодами (без коллизий). */
|
||||||
|
export function catalogCitiesWithLocCodes(): Array<CityRef & { locCode: string }> {
|
||||||
|
const used = new Set<string>()
|
||||||
|
// Сначала закрепить канонические, чтобы generate не занял msk раньше Москвы
|
||||||
|
const ordered = [...CITIES].sort((a, b) => {
|
||||||
|
const ak = CANONICAL_LOC_CODES[a.name.toLowerCase()] ? 0 : 1
|
||||||
|
const bk = CANONICAL_LOC_CODES[b.name.toLowerCase()] ? 0 : 1
|
||||||
|
return ak - bk
|
||||||
|
})
|
||||||
|
|
||||||
|
return ordered.map((city) => {
|
||||||
|
const locCode = locCodeForCity(city, used)
|
||||||
|
used.add(locCode)
|
||||||
|
return { ...city, locCode }
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
export * from "./app-switcher.js";
|
export * from "./app-switcher.js";
|
||||||
export * from "./settings.js";
|
export * from "./settings.js";
|
||||||
export * from "./fleet.js";
|
export * from "./fleet.js";
|
||||||
|
export * from "./geo/index.js";
|
||||||
|
|
||||||
export class ValidationError extends Error {
|
export class ValidationError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user