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
+7 -1
View File
@@ -2863,6 +2863,12 @@ type AliasRow = {
};
declare function listLocations(db: Db): 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 getZone(db: Db, zoneId: string): ZoneRow;
declare function createZone(db: Db, input: {
@@ -2987,4 +2993,4 @@ declare function dashboardCounts(db: Db): {
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 };
+62
View File
@@ -236,6 +236,7 @@ function updateAppSettings(db, patch) {
// src/fleet-repo.ts
import { randomUUID } from "crypto";
import { and, asc, count, eq as eq2, sql as sql2 } from "drizzle-orm";
import { catalogCitiesWithLocCodes } from "@cdnmanager/shared";
function now() {
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}`);
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) {
return db.select().from(zones).orderBy(asc(zones.name)).all().map(mapZone);
}
@@ -645,6 +705,8 @@ export {
deleteAlias,
deleteNode,
deleteZone,
ensureCatalogLocations,
findLocationByCity,
getAlias,
getAppSettings,
getLocation,
+77
View File
@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto";
import { and, asc, count, eq, sql } from "drizzle-orm";
import { catalogCitiesWithLocCodes } from "@cdnmanager/shared";
import type { Db } from "./client.js";
import { NotFoundError, ConflictError } from "./errors.js";
import {
@@ -126,6 +127,82 @@ export function getLocation(db: Db, locationId: string): LocationRow {
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[] {
return db
.select()