fix(web): фильтры VPS, вкладки дашборда и здоровье инвентаря
Docker / build (push) Has been cancelled

Фильтры стран и городов показывают только значения из БД; отключено автозаполнение поиска. Вкладки дашборда приведены к line tabs по REUI. VPS без проекта больше не считается проблемой.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-06-28 01:15:57 +07:00
co-authored by Cursor
parent 921feb926f
commit 5d83b1a95d
6 changed files with 57 additions and 35 deletions
-1
View File
@@ -96,7 +96,6 @@ export function computeDashboardStats(): DashboardStats {
}).length
let issuesCount = 0
if (activeVps.some((v) => !(v.project || '').trim())) issuesCount++
if (
activeVps.some((v) => {
const dr = Number(v.dailyRate || 0)
@@ -339,6 +339,9 @@ export function VpsFiltersToolbar({
value={filters.search}
onChange={(e) => onChange({ ...filters, search: e.target.value })}
className="pl-8"
autoComplete="off"
name="vps-inventory-search"
spellCheck={false}
/>
</div>
-5
View File
@@ -87,11 +87,6 @@ export function computeInventoryHealth(input: InventoryHealthInput): InventoryIs
const issues: InventoryIssue[] = []
const noProject = vps.filter((v) => v.status === 'active' && !(v.project || '').trim())
if (noProject.length) {
issues.push({ key: 'no-project', title: 'Активные VPS без проекта', count: noProject.length, to: '/vps?health=no-project' })
}
const noRate = vps.filter((v) => {
if (v.status !== 'active') return false
const dr = Number(v.dailyRate || 0)
+22 -10
View File
@@ -27,6 +27,7 @@ import type { DataTableColumn } from '@/components/data-grid-types'
import { dataGridCellStack } from '@/components/data-grid-cells'
import { SectionCardsSkeleton, TableSkeleton } from '@/components/skeletons'
import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@cfdm/ui/components/badge'
import { Alert, AlertDescription, AlertTitle } from '@cfdm/ui/components/alert'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
import { StatusBadge } from '@/components/status-badge'
@@ -306,25 +307,36 @@ function DashboardPage() {
/>
</div>
<Tabs defaultValue="issues">
<TabsList>
<TabsTrigger value="issues">Проблемы ({issues.length})</TabsTrigger>
<TabsTrigger value="recent">Последние VPS</TabsTrigger>
<TabsTrigger value="risk">Аккаунты ({atRisk.length})</TabsTrigger>
<Tabs defaultValue="issues" className="gap-4">
<TabsList
variant="line"
className="**:data-[slot=badge]:size-5 **:data-[slot=badge]:rounded-full **:data-[slot=badge]:bg-muted-foreground/30 **:data-[slot=badge]:px-1"
>
<TabsTrigger value="issues" className="flex-none gap-2 px-3 py-1.5 font-normal">
Проблемы
{issues.length > 0 ? <Badge variant="secondary">{issues.length}</Badge> : null}
</TabsTrigger>
<TabsTrigger value="recent" className="flex-none px-3 py-1.5 font-normal">
Последние VPS
</TabsTrigger>
<TabsTrigger value="risk" className="flex-none gap-2 px-3 py-1.5 font-normal">
Аккаунты
{atRisk.length > 0 ? <Badge variant="secondary">{atRisk.length}</Badge> : null}
</TabsTrigger>
</TabsList>
<TabsContent value="issues" className="mt-4">
<TabsContent value="issues" className="mt-0">
<DataGridCard
title="Здоровье инвентаря"
description="Нет проекта, ставки, просрочка, устаревший синк, расхождения баланса"
description="Ставка, просрочка оплаты, устаревший синк, расхождения баланса"
columns={columnDefFromDataTable(issueColumns)}
data={issues}
rowId={(i) => i.key}
emptyTitle="Проблем не найдено"
emptyDescription="Все активные VPS имеют проект, ставку и актуальный синк"
emptyDescription="Критичных проблем в инвентаре не обнаружено"
pagination={false}
/>
</TabsContent>
<TabsContent value="recent" className="mt-4">
<TabsContent value="recent" className="mt-0">
<DataGridCard
title="Последние VPS"
description="Активные серверы"
@@ -340,7 +352,7 @@ function DashboardPage() {
onRowClick={(v) => navigate({ to: '/vps', search: { edit: v.id } })}
/>
</TabsContent>
<TabsContent value="risk" className="mt-4">
<TabsContent value="risk" className="mt-0">
<DataGridCard
title="Аккаунты под риском"
description="Низкий баланс или устаревший синк BILLmanager"
+25 -16
View File
@@ -203,9 +203,7 @@ function VpsPage() {
balanceLedger: snapshot.balanceLedger,
now,
}
if (health === 'no-project') {
rows = rows.filter((v) => v.status === 'active' && !(v.project || '').trim())
} else if (health === 'no-rate') {
if (health === 'no-rate') {
rows = rows.filter((v) => {
if (v.status !== 'active') return false
const dr = Number(v.dailyRate || 0)
@@ -224,16 +222,17 @@ function VpsPage() {
return rows
}, [snapshot, filters, health])
const countryOptions = useMemo(() => {
const dbCountryNames = useMemo(() => {
const names = new Set<string>()
// Из существующих VPS — могут быть произвольные строки
for (const v of snapshot?.vps ?? []) {
const c = (v.country || '').trim()
if (c) names.add(c)
}
// Из стандартизированного справочника
for (const c of COUNTRIES) names.add(c.name)
return [...names].sort((a, b) => a.localeCompare(b, 'ru')).map((name) => {
return names
}, [snapshot?.vps])
const mapCountryOptions = (names: Iterable<string>) =>
[...names].sort((a, b) => a.localeCompare(b, 'ru')).map((name) => {
const ref = COUNTRY_BY_NAME_RU[name.toLowerCase()]
return {
value: name,
@@ -242,10 +241,20 @@ function VpsPage() {
leading: <CountryFlag code={ref?.code} country={name} />,
}
})
}, [snapshot?.vps])
const cityOptions = useMemo(
() => buildCityOptions(snapshot?.vps, filters.country[0]),
const filterCountryOptions = useMemo(
() => mapCountryOptions(dbCountryNames),
[dbCountryNames],
)
const formCountryOptions = useMemo(() => {
const names = new Set(dbCountryNames)
for (const c of COUNTRIES) names.add(c.name)
return mapCountryOptions(names)
}, [dbCountryNames])
const filterCityOptions = useMemo(
() => buildCityOptions(snapshot?.vps, filters.country[0], { includeCatalog: false }),
[snapshot?.vps, filters.country],
)
@@ -425,8 +434,8 @@ function VpsPage() {
providerAccounts={snap.providerAccounts}
vps={snap.vps}
projectNameOptions={projectNameOptions}
countryOptions={countryOptions}
cityOptions={cityOptions}
countryOptions={filterCountryOptions}
cityOptions={filterCityOptions}
/>
<EmptyState
title="Ничего не найдено"
@@ -450,8 +459,8 @@ function VpsPage() {
providerAccounts={snap.providerAccounts}
vps={snap.vps}
projectNameOptions={projectNameOptions}
countryOptions={countryOptions}
cityOptions={cityOptions}
countryOptions={filterCountryOptions}
cityOptions={filterCityOptions}
/>
{tableSections.map((section) => (
<DataGridCard
@@ -532,7 +541,7 @@ function VpsPage() {
setValue('city', '')
}
}}
options={countryOptions}
options={formCountryOptions}
searchPlaceholder="Поиск страны…"
emptyText="Нет вариантов"
/>
+7 -3
View File
@@ -217,11 +217,13 @@ export function cityMatchesCountry(
)
}
/** Опции городов: из VPS и справочника, опционально по стране. */
/** Опции городов: из 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
@@ -245,8 +247,10 @@ export function buildCityOptions(
}
}
for (const { name } of listCities(countryCode)) {
names.add(name)
if (includeCatalog) {
for (const { name } of listCities(countryCode)) {
names.add(name)
}
}
return [...names]