fix(ui): полный справочник стран и городов как в VPS Tracker
quality / commitlint (push) Skipped
CD / update-wiki (push) Successful in 5s
quality / changes (push) Successful in 4s
quality / docker-check (push) Skipped
quality / web (push) Successful in 45s
quality / api (push) Successful in 36s
CD / quality (push) Successful in 1m30s
CD / publish (push) Successful in 1m51s

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-09-04 15:15:34 +07:00
co-authored by Cursor
parent 146afc1369
commit 654b08c8e5
18 changed files with 1400 additions and 66 deletions
+2
View File
@@ -3,6 +3,7 @@ import fp from "fastify-plugin";
import {
createDb,
createMemoryDb,
ensureCatalogLocations,
healthCheck,
runMigrations,
type Db,
@@ -31,6 +32,7 @@ async function dbPlugin(
: createDb(opts.config!.databaseUrl);
runMigrations(sqlite);
ensureCatalogLocations(db);
app.decorate("db", db);
app.decorate("sqlite", sqlite);
+7 -1
View File
@@ -1,4 +1,5 @@
import { cn } from '@cdnmanager/ui/lib/utils'
import { COUNTRY_BY_NAME_RU } from '@cdnmanager/shared'
import { countryCodeFromName, getCountryFlagUrl } from '@/lib/country-labels'
interface CountryFlagProps {
@@ -8,7 +9,12 @@ interface CountryFlagProps {
}
export function CountryFlag({ code, country, className }: CountryFlagProps) {
const resolvedCode = code ?? (country ? countryCodeFromName(country) : undefined)
const resolvedCode =
code ??
(country
? COUNTRY_BY_NAME_RU[country.trim().toLowerCase()]?.code ??
countryCodeFromName(country)
: undefined)
const url = getCountryFlagUrl(resolvedCode)
if (!url) return null
+6 -14
View File
@@ -1,26 +1,18 @@
/** ISO → русское название (seed fleet locations). */
const COUNTRY_NAME_BY_CODE: Record<string, string> = {
RU: 'Россия',
DE: 'Германия',
NL: 'Нидерланды',
FI: 'Финляндия',
FR: 'Франция',
}
const COUNTRY_CODE_BY_NAME: Record<string, string> = Object.fromEntries(
Object.entries(COUNTRY_NAME_BY_CODE).map(([code, name]) => [name.toLowerCase(), code]),
)
import {
COUNTRY_BY_CODE,
COUNTRY_BY_NAME_RU,
} from '@cdnmanager/shared'
export function countryNameFromCode(code: string | null | undefined): string {
if (!code) return ''
return COUNTRY_NAME_BY_CODE[code.toUpperCase()] ?? code
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_CODE_BY_NAME[trimmed.toLowerCase()] ?? ''
return COUNTRY_BY_NAME_RU[trimmed.toLowerCase()]?.code ?? ''
}
export function getCountryFlagUrl(code?: string): string | undefined {
+69 -49
View File
@@ -15,6 +15,13 @@ import {
} from 'lucide-react'
import { toast } from 'sonner'
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 { PageHeader } from '@/components/page-header'
import { ResourcePage } from '@/components/reui-kit'
@@ -124,36 +131,57 @@ function NodesPage() {
const countryCode = countryCodeFromName(countryName)
const locationRowsForGeo = useMemo(
() =>
locations.map((l) => ({
city: l.name,
country: countryNameFromCode(l.country),
})),
[locations],
)
const countryOptions = useMemo(() => {
const codes = new Set<string>()
const names = new Set(COUNTRIES.map((c) => c.name))
for (const loc of locations) {
if (loc.country) codes.add(loc.country)
const n = countryNameFromCode(loc.country)
if (n) names.add(n)
}
return [...codes]
.map((code) => {
const name = countryNameFromCode(code)
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={code} country={name} />,
leading: <CountryFlag code={ref?.code} country={name} />,
}
})
.sort((a, b) => a.label.localeCompare(b.label, 'ru'))
}, [locations])
const locationOptions = useMemo(() => {
if (!countryCode) return []
return locations
.filter((l) => l.country === countryCode)
.map((l) => ({
value: `${l.code}${l.name}`,
label: `${l.code}${l.name}`,
}))
}, [locations, countryCode])
const cityOptions = useMemo(
() =>
buildCityOptions(locationRowsForGeo, countryName.trim() || undefined, {
includeCatalog: true,
}),
[locationRowsForGeo, countryName],
)
function locationLabel(locationId: string): string {
const loc = locations.find((l) => l.id === locationId)
return loc ? `${loc.code}${loc.name}` : ''
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 {
@@ -161,17 +189,6 @@ function NodesPage() {
return countryNameFromCode(code)
}
function locationIdFromDisplay(display: string): string {
const q = display.trim().toLowerCase()
if (!q) return ''
const match = locations.find((l) => {
if (countryCode && l.country !== countryCode) return false
const label = `${l.code}${l.name}`.toLowerCase()
return label === q || l.code.toLowerCase() === q || l.name.toLowerCase() === q
})
return match?.id ?? ''
}
async function refreshPreview(
overrides?: Partial<{
zoneId: string
@@ -377,7 +394,7 @@ function NodesPage() {
const n = row.original
setEditing(n)
setCountryName(countryNameForLocationId(n.locationId))
setLocationQuery(locationLabel(n.locationId))
setLocationQuery(cityNameForLocationId(n.locationId))
form.reset({
zoneId: n.zoneId,
locationId: n.locationId,
@@ -418,12 +435,11 @@ function NodesPage() {
size="sm"
onClick={() => {
setEditing(null)
const firstLoc = locations[0]
setCountryName(countryNameFromCode(firstLoc?.country))
setLocationQuery(firstLoc ? `${firstLoc.code}${firstLoc.name}` : '')
setCountryName('')
setLocationQuery('')
form.reset({
zoneId: zones[0]?.id ?? '',
locationId: firstLoc?.id ?? '',
locationId: '',
role: 'gw',
indexNum: 1,
ipv4: '',
@@ -517,11 +533,13 @@ function NodesPage() {
value={countryName}
onChange={(v) => {
setCountryName(v)
const nextCode = countryCodeFromName(v)
const currentLoc = locations.find((l) => l.id === form.getValues('locationId'))
if (!nextCode || !currentLoc || currentLoc.country !== nextCode) {
form.setValue('locationId', '')
if (
v.trim() &&
locationQuery.trim() &&
!cityMatchesCountry(locationQuery, v, locationRowsForGeo)
) {
setLocationQuery('')
form.setValue('locationId', '')
setPreviewHost('')
setPreviewRecords([])
}
@@ -531,28 +549,30 @@ function NodesPage() {
emptyText="Нет вариантов"
/>
</FormFieldSimple>
<FormFieldSimple label="Локация" htmlFor="node-location">
<FormFieldSimple label="Город" htmlFor="node-city">
<AutoCompleteInput
id="node-location"
placeholder="Любая"
id="node-city"
placeholder="Любой"
value={locationQuery}
onChange={(v) => {
setLocationQuery(v)
const id = locationIdFromDisplay(v)
const resolvedCountry =
resolveCountryForCityFromRows(v, locationRowsForGeo) ?? countryName
if (resolvedCountry && resolvedCountry !== countryName) {
setCountryName(resolvedCountry)
}
const id = locationIdForCity(v, resolvedCountry || countryName)
form.setValue('locationId', id)
const loc = locations.find((l) => l.id === id)
if (loc?.country) setCountryName(countryNameFromCode(loc.country))
if (id) void refreshPreview({ locationId: id })
else {
setPreviewHost('')
setPreviewRecords([])
}
}}
options={locationOptions}
searchPlaceholder="Поиск локации…"
emptyText={countryCode ? 'Нет вариантов' : 'Сначала выберите страну'}
options={cityOptions}
searchPlaceholder="Поиск города…"
emptyText="Нет вариантов"
showLeadingInInput={false}
disabled={!countryCode}
/>
</FormFieldSimple>