fix(web): фильтры VPS, вкладки дашборда и здоровье инвентаря
Docker / build (push) Has been cancelled
Docker / build (push) Has been cancelled
Фильтры стран и городов показывают только значения из БД; отключено автозаполнение поиска. Вкладки дашборда приведены к line tabs по REUI. VPS без проекта больше не считается проблемой. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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="Нет вариантов"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user