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 }).length
let issuesCount = 0 let issuesCount = 0
if (activeVps.some((v) => !(v.project || '').trim())) issuesCount++
if ( if (
activeVps.some((v) => { activeVps.some((v) => {
const dr = Number(v.dailyRate || 0) const dr = Number(v.dailyRate || 0)
@@ -339,6 +339,9 @@ export function VpsFiltersToolbar({
value={filters.search} value={filters.search}
onChange={(e) => onChange({ ...filters, search: e.target.value })} onChange={(e) => onChange({ ...filters, search: e.target.value })}
className="pl-8" className="pl-8"
autoComplete="off"
name="vps-inventory-search"
spellCheck={false}
/> />
</div> </div>
-5
View File
@@ -87,11 +87,6 @@ export function computeInventoryHealth(input: InventoryHealthInput): InventoryIs
const issues: InventoryIssue[] = [] 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) => { const noRate = vps.filter((v) => {
if (v.status !== 'active') return false if (v.status !== 'active') return false
const dr = Number(v.dailyRate || 0) 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 { dataGridCellStack } from '@/components/data-grid-cells'
import { SectionCardsSkeleton, TableSkeleton } from '@/components/skeletons' import { SectionCardsSkeleton, TableSkeleton } from '@/components/skeletons'
import { Button } from '@cfdm/ui/components/button' import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@cfdm/ui/components/badge'
import { Alert, AlertDescription, AlertTitle } from '@cfdm/ui/components/alert' import { Alert, AlertDescription, AlertTitle } from '@cfdm/ui/components/alert'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
@@ -306,25 +307,36 @@ function DashboardPage() {
/> />
</div> </div>
<Tabs defaultValue="issues"> <Tabs defaultValue="issues" className="gap-4">
<TabsList> <TabsList
<TabsTrigger value="issues">Проблемы ({issues.length})</TabsTrigger> variant="line"
<TabsTrigger value="recent">Последние VPS</TabsTrigger> 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="risk">Аккаунты ({atRisk.length})</TabsTrigger> >
<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> </TabsList>
<TabsContent value="issues" className="mt-4"> <TabsContent value="issues" className="mt-0">
<DataGridCard <DataGridCard
title="Здоровье инвентаря" title="Здоровье инвентаря"
description="Нет проекта, ставки, просрочка, устаревший синк, расхождения баланса" description="Ставка, просрочка оплаты, устаревший синк, расхождения баланса"
columns={columnDefFromDataTable(issueColumns)} columns={columnDefFromDataTable(issueColumns)}
data={issues} data={issues}
rowId={(i) => i.key} rowId={(i) => i.key}
emptyTitle="Проблем не найдено" emptyTitle="Проблем не найдено"
emptyDescription="Все активные VPS имеют проект, ставку и актуальный синк" emptyDescription="Критичных проблем в инвентаре не обнаружено"
pagination={false} pagination={false}
/> />
</TabsContent> </TabsContent>
<TabsContent value="recent" className="mt-4"> <TabsContent value="recent" className="mt-0">
<DataGridCard <DataGridCard
title="Последние VPS" title="Последние VPS"
description="Активные серверы" description="Активные серверы"
@@ -340,7 +352,7 @@ function DashboardPage() {
onRowClick={(v) => navigate({ to: '/vps', search: { edit: v.id } })} onRowClick={(v) => navigate({ to: '/vps', search: { edit: v.id } })}
/> />
</TabsContent> </TabsContent>
<TabsContent value="risk" className="mt-4"> <TabsContent value="risk" className="mt-0">
<DataGridCard <DataGridCard
title="Аккаунты под риском" title="Аккаунты под риском"
description="Низкий баланс или устаревший синк BILLmanager" description="Низкий баланс или устаревший синк BILLmanager"
+25 -16
View File
@@ -203,9 +203,7 @@ function VpsPage() {
balanceLedger: snapshot.balanceLedger, balanceLedger: snapshot.balanceLedger,
now, now,
} }
if (health === 'no-project') { if (health === 'no-rate') {
rows = rows.filter((v) => v.status === 'active' && !(v.project || '').trim())
} else if (health === 'no-rate') {
rows = rows.filter((v) => { rows = rows.filter((v) => {
if (v.status !== 'active') return false if (v.status !== 'active') return false
const dr = Number(v.dailyRate || 0) const dr = Number(v.dailyRate || 0)
@@ -224,16 +222,17 @@ function VpsPage() {
return rows return rows
}, [snapshot, filters, health]) }, [snapshot, filters, health])
const countryOptions = useMemo(() => { const dbCountryNames = useMemo(() => {
const names = new Set<string>() const names = new Set<string>()
// Из существующих VPS — могут быть произвольные строки
for (const v of snapshot?.vps ?? []) { for (const v of snapshot?.vps ?? []) {
const c = (v.country || '').trim() const c = (v.country || '').trim()
if (c) names.add(c) if (c) names.add(c)
} }
// Из стандартизированного справочника return names
for (const c of COUNTRIES) names.add(c.name) }, [snapshot?.vps])
return [...names].sort((a, b) => a.localeCompare(b, 'ru')).map((name) => {
const mapCountryOptions = (names: Iterable<string>) =>
[...names].sort((a, b) => a.localeCompare(b, 'ru')).map((name) => {
const ref = COUNTRY_BY_NAME_RU[name.toLowerCase()] const ref = COUNTRY_BY_NAME_RU[name.toLowerCase()]
return { return {
value: name, value: name,
@@ -242,10 +241,20 @@ function VpsPage() {
leading: <CountryFlag code={ref?.code} country={name} />, leading: <CountryFlag code={ref?.code} country={name} />,
} }
}) })
}, [snapshot?.vps])
const cityOptions = useMemo( const filterCountryOptions = useMemo(
() => buildCityOptions(snapshot?.vps, filters.country[0]), () => 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], [snapshot?.vps, filters.country],
) )
@@ -425,8 +434,8 @@ function VpsPage() {
providerAccounts={snap.providerAccounts} providerAccounts={snap.providerAccounts}
vps={snap.vps} vps={snap.vps}
projectNameOptions={projectNameOptions} projectNameOptions={projectNameOptions}
countryOptions={countryOptions} countryOptions={filterCountryOptions}
cityOptions={cityOptions} cityOptions={filterCityOptions}
/> />
<EmptyState <EmptyState
title="Ничего не найдено" title="Ничего не найдено"
@@ -450,8 +459,8 @@ function VpsPage() {
providerAccounts={snap.providerAccounts} providerAccounts={snap.providerAccounts}
vps={snap.vps} vps={snap.vps}
projectNameOptions={projectNameOptions} projectNameOptions={projectNameOptions}
countryOptions={countryOptions} countryOptions={filterCountryOptions}
cityOptions={cityOptions} cityOptions={filterCityOptions}
/> />
{tableSections.map((section) => ( {tableSections.map((section) => (
<DataGridCard <DataGridCard
@@ -532,7 +541,7 @@ function VpsPage() {
setValue('city', '') setValue('city', '')
} }
}} }}
options={countryOptions} options={formCountryOptions}
searchPlaceholder="Поиск страны…" searchPlaceholder="Поиск страны…"
emptyText="Нет вариантов" emptyText="Нет вариантов"
/> />
+7 -3
View File
@@ -217,11 +217,13 @@ export function cityMatchesCountry(
) )
} }
/** Опции городов: из VPS и справочника, опционально по стране. */ /** Опции городов: из VPS и опционально справочника, опционально по стране. */
export function buildCityOptions( export function buildCityOptions(
rows: readonly CityLocationRow[] | undefined, rows: readonly CityLocationRow[] | undefined,
countryName?: string, countryName?: string,
options?: { includeCatalog?: boolean },
): { value: string; label: string }[] { ): { value: string; label: string }[] {
const includeCatalog = options?.includeCatalog ?? true
const names = new Set<string>() const names = new Set<string>()
const countryCode = countryName?.trim() const countryCode = countryName?.trim()
? COUNTRY_BY_NAME_RU[countryName.trim().toLowerCase()]?.code ? COUNTRY_BY_NAME_RU[countryName.trim().toLowerCase()]?.code
@@ -245,8 +247,10 @@ export function buildCityOptions(
} }
} }
for (const { name } of listCities(countryCode)) { if (includeCatalog) {
names.add(name) for (const { name } of listCities(countryCode)) {
names.add(name)
}
} }
return [...names] return [...names]