Добавлены bulk-операции VPS, карточка /vps/:id, CRUD проектов, Command Palette, календарь продлений, webhooks, uptime-проверки, audit log, кастомные поля и адаптеры провайдеров. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
0ea92746e4
commit
11b67a1cef
43 files changed
+2085
-79
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
WalletIcon,
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||
import { api, ApiError } from '@/lib/api-client'
|
||||
@@ -21,6 +22,7 @@ import type { DataTableColumn } from '@/components/data-grid-types'
|
||||
import { dataGridCellStack } from '@/components/data-grid-cells'
|
||||
import { CrudListPage } from '@/components/crud-list-page'
|
||||
import { RowActions } from '@/components/row-actions'
|
||||
import { HealthModeBanner } from '@/components/health-mode-banner'
|
||||
import {
|
||||
ProviderAccountEditSheet,
|
||||
providerAccountFormDefaults,
|
||||
@@ -30,14 +32,24 @@ import { accountBalanceApi, accountBalanceCurrency } from '@/lib/account'
|
||||
import type { ProviderAccount } from '@/types/entities'
|
||||
import { providerByIdMap, accountBillmanagerUiReady, billmanagerSyncableAccounts } from '@/lib/billmanager'
|
||||
import { billingModeLabel, formatCurrency } from '@/lib/format'
|
||||
import {
|
||||
getBalanceMismatchAccountIds,
|
||||
getStaleSyncAccountIds,
|
||||
} from '@/lib/inventory-health'
|
||||
|
||||
const accountsSearchSchema = z.object({
|
||||
health: z.string().optional(),
|
||||
})
|
||||
|
||||
export const Route = createFileRoute('/_auth/accounts')({
|
||||
validateSearch: (search) => accountsSearchSchema.parse(search),
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
queryClient.ensureQueryData(snapshotQueryOptions()),
|
||||
component: AccountsPage,
|
||||
})
|
||||
|
||||
function AccountsPage() {
|
||||
const { health } = Route.useSearch()
|
||||
const queryClient = useQueryClient()
|
||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||
const [open, setOpen] = useState(false)
|
||||
@@ -129,6 +141,22 @@ function AccountsPage() {
|
||||
? billmanagerSyncableAccounts(snapshot.providerAccounts, snapshot.providers).length
|
||||
: 0
|
||||
|
||||
const filteredAccounts = useMemo(() => {
|
||||
const accounts = snapshot?.providerAccounts ?? []
|
||||
if (!health || !snapshot) return accounts
|
||||
if (health === 'stale-sync') {
|
||||
const ids = new Set(
|
||||
getStaleSyncAccountIds(snapshot.providerAccounts, snapshot.providers, snapshot.syncLog ?? []),
|
||||
)
|
||||
return accounts.filter((a) => ids.has(a.id))
|
||||
}
|
||||
if (health === 'balance-mismatch') {
|
||||
const ids = new Set(getBalanceMismatchAccountIds(snapshot.providerAccounts, snapshot.balanceLedger))
|
||||
return accounts.filter((a) => ids.has(a.id))
|
||||
}
|
||||
return accounts
|
||||
}, [snapshot, health])
|
||||
|
||||
const columns: DataTableColumn<ProviderAccount>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
@@ -250,17 +278,22 @@ function AccountsPage() {
|
||||
providers={snapshot.providers}
|
||||
onSubmit={(values) => saveMut.mutate(values)}
|
||||
submitting={saveMut.isPending}
|
||||
onBalanceRefreshed={() => void queryClient.invalidateQueries({ queryKey: ['snapshot'] })}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{(snap) => (
|
||||
<DataGridCard
|
||||
columns={columnDefFromDataTable(columns)}
|
||||
data={snap.providerAccounts}
|
||||
rowId={(a) => a.id}
|
||||
pinLastColumn
|
||||
/>
|
||||
{( ) => (
|
||||
<div className="flex flex-col gap-4">
|
||||
{health ? <HealthModeBanner health={health} exitTo="/accounts" /> : null}
|
||||
<DataGridCard
|
||||
columns={columnDefFromDataTable(columns)}
|
||||
data={filteredAccounts}
|
||||
rowId={(a) => a.id}
|
||||
pinLastColumn
|
||||
emptyTitle={health ? 'Нет аккаунтов с этой проблемой' : 'Нет записей'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CrudListPage>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { HistoryIcon } from 'lucide-react'
|
||||
|
||||
import { api } from '@/lib/api-client'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
||||
import type { DataTableColumn } from '@/components/data-grid-types'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
|
||||
interface AuditRow {
|
||||
id: string
|
||||
entity: string
|
||||
entityId: string
|
||||
action: string
|
||||
diff: Record<string, unknown> | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
create: 'Создание',
|
||||
update: 'Изменение',
|
||||
delete: 'Удаление',
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/_auth/audit')({
|
||||
component: AuditPage,
|
||||
})
|
||||
|
||||
function AuditPage() {
|
||||
const { data, isLoading, isError, error, refetch } = useQuery({
|
||||
queryKey: ['audit'],
|
||||
queryFn: () => api.fetchAuditLog(200),
|
||||
})
|
||||
|
||||
const columns: DataTableColumn<AuditRow>[] = [
|
||||
{
|
||||
key: 'createdAt',
|
||||
header: 'Время',
|
||||
icon: HistoryIcon,
|
||||
sortValue: (r) => r.createdAt,
|
||||
cell: (r) => (
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{new Date(r.createdAt).toLocaleString('ru-RU')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'entity',
|
||||
header: 'Сущность',
|
||||
cell: (r) => <Badge variant="outline">{r.entity}</Badge>,
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
header: 'Действие',
|
||||
cell: (r) => ACTION_LABELS[r.action] ?? r.action,
|
||||
},
|
||||
{
|
||||
key: 'entityId',
|
||||
header: 'ID',
|
||||
cell: (r) =>
|
||||
r.entity === 'vps' ? (
|
||||
<Button variant="link" className="h-auto p-0" render={<Link to="/vps/$vpsId" params={{ vpsId: r.entityId }} />}>
|
||||
{r.entityId}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="font-mono text-xs">{r.entityId}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'diff',
|
||||
header: 'Изменения',
|
||||
sortable: false,
|
||||
cell: (r) => (
|
||||
<span className="max-w-md truncate text-xs text-muted-foreground">
|
||||
{r.diff ? JSON.stringify(r.diff) : '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader title="Журнал изменений" description="История ручных правок через API" />
|
||||
<QueryState
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={() => refetch()}
|
||||
empty={!data?.length}
|
||||
emptyTitle="Записей нет"
|
||||
emptyDescription="Изменения VPS появятся здесь после CRUD-операций"
|
||||
>
|
||||
{(rows) => (
|
||||
<DataGridCard
|
||||
columns={columnDefFromDataTable(columns)}
|
||||
data={rows as AuditRow[]}
|
||||
rowId={(r) => r.id}
|
||||
pageSize={25}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -33,10 +33,15 @@ function PaymentsPage() {
|
||||
)
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: (r: PaymentFormValues) =>
|
||||
r.id
|
||||
? api.update<Payment>('payments', r.id, r as unknown as Partial<Payment>)
|
||||
: api.create('payments', r as unknown as Payment),
|
||||
mutationFn: (r: PaymentFormValues) => {
|
||||
const payload = {
|
||||
...r,
|
||||
vpsId: r.vpsId?.trim() || undefined,
|
||||
}
|
||||
return r.id
|
||||
? api.update<Payment>('payments', r.id, payload as unknown as Partial<Payment>)
|
||||
: api.create('payments', payload as unknown as Payment)
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
||||
toast.success('Платёж сохранён')
|
||||
@@ -66,6 +71,7 @@ function PaymentsPage() {
|
||||
amount: p.amount,
|
||||
currency: p.currency,
|
||||
providerAccountId: p.providerAccountId,
|
||||
vpsId: (p as Payment & { vpsId?: string }).vpsId ?? '',
|
||||
note: p.note ?? '',
|
||||
}),
|
||||
)
|
||||
@@ -174,6 +180,7 @@ function PaymentsPage() {
|
||||
defaultValues={formDefaults}
|
||||
providerAccounts={snapshot.providerAccounts}
|
||||
providers={snapshot.providers}
|
||||
vpsRows={snapshot.vps}
|
||||
onSubmit={(values) => saveMut.mutate(values)}
|
||||
submitting={saveMut.isPending}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { PlusIcon, FolderKanbanIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
@@ -9,13 +9,16 @@ import { api, ApiError } from '@/lib/api-client'
|
||||
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
||||
import type { DataTableColumn } from '@/components/data-grid-types'
|
||||
import { CrudListPage } from '@/components/crud-list-page'
|
||||
import { RowActions } from '@/components/row-actions'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { ProjectEditSheet } from '@/components/domain/project-edit-sheet'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import { ProjectEditSheet, projectFormDefaults } from '@/components/domain/project-edit-sheet'
|
||||
import type { ProjectFormValues } from '@/lib/schemas'
|
||||
|
||||
interface ProjectRow {
|
||||
id: string
|
||||
name: string
|
||||
color?: string | null
|
||||
vpsCount: number
|
||||
}
|
||||
|
||||
@@ -29,29 +32,72 @@ function ProjectsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||
const [open, setOpen] = useState(false)
|
||||
const [formDefaults, setFormDefaults] = useState<ProjectFormValues>(projectFormDefaults())
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (values: ProjectFormValues) => api.createProject(values.name.trim()),
|
||||
const saveMut = useMutation({
|
||||
mutationFn: (values: ProjectFormValues) => {
|
||||
const color = values.color?.trim() || null
|
||||
if (values.id) {
|
||||
return api.updateProject(values.id, { name: values.name.trim(), color })
|
||||
}
|
||||
return api.createProject(values.name.trim())
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
||||
toast.success('Проект создан')
|
||||
toast.success('Проект сохранён')
|
||||
setOpen(false)
|
||||
},
|
||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
|
||||
})
|
||||
|
||||
const rows: ProjectRow[] = (snapshot?.serverProjects ?? []).map((p) => {
|
||||
const row = p as { id: string; name: string }
|
||||
const vpsCount = (snapshot?.vps ?? []).filter((v) => v.project === row.name).length
|
||||
return { id: row.id, name: row.name, vpsCount }
|
||||
const delMut = useMutation({
|
||||
mutationFn: (id: string) => api.deleteProject(id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
||||
toast.success('Проект удалён')
|
||||
},
|
||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
|
||||
})
|
||||
|
||||
const rows: ProjectRow[] = useMemo(
|
||||
() =>
|
||||
(snapshot?.serverProjects ?? []).map((p) => {
|
||||
const row = p as { id: string; name: string; color?: string | null }
|
||||
const vpsCount = (snapshot?.vps ?? []).filter((v) => v.project === row.name).length
|
||||
return { id: row.id, name: row.name, color: row.color, vpsCount }
|
||||
}),
|
||||
[snapshot],
|
||||
)
|
||||
|
||||
const openCreate = () => {
|
||||
setFormDefaults(projectFormDefaults())
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (row: ProjectRow) => {
|
||||
setFormDefaults(projectFormDefaults({ id: row.id, name: row.name, color: row.color ?? '' }))
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const columns: DataTableColumn<ProjectRow>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Проект',
|
||||
icon: FolderKanbanIcon,
|
||||
cell: (row) => <span className="font-medium">{row.name}</span>,
|
||||
cell: (row) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{row.color ? (
|
||||
<span className="size-2.5 shrink-0 rounded-full" style={{ backgroundColor: row.color }} />
|
||||
) : null}
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 font-medium"
|
||||
render={<Link to="/vps" search={{ project: row.name }} />}
|
||||
>
|
||||
{row.name}
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'vps',
|
||||
@@ -59,7 +105,29 @@ function ProjectsPage() {
|
||||
headerClassName: 'text-right',
|
||||
className: 'text-right tabular-nums',
|
||||
sortValue: (row) => row.vpsCount,
|
||||
cell: (row) => row.vpsCount,
|
||||
cell: (row) => (
|
||||
<Badge variant="secondary">{row.vpsCount}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
sortable: false,
|
||||
className: 'w-24 text-right',
|
||||
cell: (row) => (
|
||||
<RowActions
|
||||
onEdit={() => openEdit(row)}
|
||||
onDelete={() => {
|
||||
if (row.vpsCount > 0) {
|
||||
toast.error(`Нельзя удалить: к проекту привязано ${row.vpsCount} VPS`)
|
||||
return
|
||||
}
|
||||
delMut.mutate(row.id)
|
||||
}}
|
||||
deleteTitle="Удалить проект?"
|
||||
deleteDescription={`«${row.name}» будет удалён.`}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -68,7 +136,7 @@ function ProjectsPage() {
|
||||
title="Проекты"
|
||||
description="Группировка VPS по проектам"
|
||||
actions={
|
||||
<Button onClick={() => setOpen(true)}>
|
||||
<Button onClick={openCreate}>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
Добавить
|
||||
</Button>
|
||||
@@ -82,7 +150,7 @@ function ProjectsPage() {
|
||||
emptyTitle="Проектов нет"
|
||||
emptyDescription="Создайте проект или назначьте его при редактировании VPS"
|
||||
emptyAction={
|
||||
<Button onClick={() => setOpen(true)}>
|
||||
<Button onClick={openCreate}>
|
||||
<PlusIcon data-icon="inline-start" />
|
||||
Создать проект
|
||||
</Button>
|
||||
@@ -91,8 +159,9 @@ function ProjectsPage() {
|
||||
<ProjectEditSheet
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onSubmit={(values) => createMut.mutate(values)}
|
||||
submitting={createMut.isPending}
|
||||
defaultValues={formDefaults}
|
||||
onSubmit={(values) => saveMut.mutate(values)}
|
||||
submitting={saveMut.isPending}
|
||||
/>
|
||||
}
|
||||
>
|
||||
@@ -101,6 +170,7 @@ function ProjectsPage() {
|
||||
columns={columnDefFromDataTable(columns)}
|
||||
data={rows}
|
||||
rowId={(r) => r.id}
|
||||
pinLastColumn
|
||||
/>
|
||||
)}
|
||||
</CrudListPage>
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { CalendarIcon } from 'lucide-react'
|
||||
|
||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { SelectField } from '@/components/select-field'
|
||||
import { getPaidUntilDate } from '@/lib/paid-until'
|
||||
import { providerByIdMap } from '@/lib/billmanager'
|
||||
|
||||
export const Route = createFileRoute('/_auth/renewals')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
queryClient.ensureQueryData(snapshotQueryOptions()),
|
||||
component: RenewalsPage,
|
||||
})
|
||||
|
||||
type Horizon = '7' | '30' | '90'
|
||||
|
||||
interface RenewalItem {
|
||||
id: string
|
||||
kind: 'vps'
|
||||
label: string
|
||||
sublabel: string
|
||||
date: Date
|
||||
overdue: boolean
|
||||
}
|
||||
|
||||
function RenewalsPage() {
|
||||
const [horizon, setHorizon] = useState<Horizon>('30')
|
||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||
|
||||
const items = useMemo(() => {
|
||||
if (!snapshot) return []
|
||||
const now = new Date()
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const maxDays = Number(horizon)
|
||||
const maxDate = new Date(todayStart)
|
||||
maxDate.setDate(maxDate.getDate() + maxDays)
|
||||
const ctx = {
|
||||
vps: snapshot.vps,
|
||||
providerAccounts: snapshot.providerAccounts,
|
||||
payments: snapshot.payments,
|
||||
balanceLedger: snapshot.balanceLedger,
|
||||
now,
|
||||
}
|
||||
const providerById = providerByIdMap(snapshot.providers)
|
||||
const list: RenewalItem[] = []
|
||||
for (const v of snapshot.vps) {
|
||||
if (v.status !== 'active') continue
|
||||
const d = getPaidUntilDate(v, ctx)
|
||||
if (!d) continue
|
||||
if (d > maxDate) continue
|
||||
const acc = snapshot.providerAccounts.find((a) => a.id === v.providerAccountId)
|
||||
const providerName = acc ? providerById.get(acc.providerId)?.name ?? '' : ''
|
||||
list.push({
|
||||
id: v.id,
|
||||
kind: 'vps',
|
||||
label: v.ip || v.dns || v.id,
|
||||
sublabel: [acc?.name, providerName].filter(Boolean).join(' · '),
|
||||
date: d,
|
||||
overdue: d < todayStart,
|
||||
})
|
||||
}
|
||||
return list.sort((a, b) => a.date.getTime() - b.date.getTime())
|
||||
}, [snapshot, horizon])
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, RenewalItem[]>()
|
||||
for (const item of items) {
|
||||
const key = item.date.toLocaleDateString('ru-RU', { year: 'numeric', month: 'long', day: 'numeric' })
|
||||
const arr = map.get(key) ?? []
|
||||
arr.push(item)
|
||||
map.set(key, arr)
|
||||
}
|
||||
return [...map.entries()]
|
||||
}, [items])
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Продления"
|
||||
description="Календарь истечения оплаты VPS"
|
||||
actions={
|
||||
<SelectField
|
||||
value={horizon}
|
||||
onValueChange={(v) => setHorizon((v ?? '30') as Horizon)}
|
||||
options={[
|
||||
{ value: '7', label: '7 дней' },
|
||||
{ value: '30', label: '30 дней' },
|
||||
{ value: '90', label: '90 дней' },
|
||||
]}
|
||||
triggerClassName="w-36"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<QueryState
|
||||
data={snapshot}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={() => refetch()}
|
||||
empty={items.length === 0}
|
||||
emptyTitle="Нет продлений в выбранном периоде"
|
||||
emptyDescription="Активные VPS с расчётной датой оплаты не найдены"
|
||||
>
|
||||
{() => (
|
||||
<div className="flex flex-col gap-4">
|
||||
{grouped.map(([weekLabel, weekItems]) => (
|
||||
<Card key={weekLabel}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<CalendarIcon className="size-4" />
|
||||
{weekLabel}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
{weekItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2"
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto justify-start p-0 font-medium"
|
||||
render={<Link to="/vps/$vpsId" params={{ vpsId: item.id }} />}
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">{item.sublabel}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{item.overdue ? <Badge variant="destructive">Просрочено</Badge> : null}
|
||||
<span className="tabular-nums text-sm">{item.date.toLocaleDateString('ru-RU')}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</QueryState>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@cfdm/ui/components/card'
|
||||
import { FieldGroup } from '@cfdm/ui/components/field'
|
||||
import { Input } from '@cfdm/ui/components/input'
|
||||
import { Textarea } from '@cfdm/ui/components/textarea'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { SelectField } from '@/components/select-field'
|
||||
import { FormField } from '@/components/form-field'
|
||||
@@ -44,6 +45,15 @@ function settingsToFormValues(s: Settings): SettingsFormValues {
|
||||
notifyNewTariffsEnabled: s.notifyNewTariffsEnabled !== false,
|
||||
notifyLowBalanceEnabled: s.notifyLowBalanceEnabled !== false,
|
||||
notifySyncDigestEnabled: s.notifySyncDigestEnabled !== false,
|
||||
notifyVpsDownEnabled: (s as Settings & { notifyVpsDownEnabled?: boolean }).notifyVpsDownEnabled !== false,
|
||||
webhookUrl: (s as Settings & { webhookUrl?: string }).webhookUrl ?? '',
|
||||
webhookEnabled: (s as Settings & { webhookEnabled?: boolean }).webhookEnabled === true,
|
||||
customFieldsJson: JSON.stringify(
|
||||
(s as Settings & { customFields?: unknown[] }).customFields ?? [],
|
||||
null,
|
||||
2,
|
||||
),
|
||||
telegramMessageThreadId: (s as Settings & { telegramMessageThreadId?: string }).telegramMessageThreadId ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,11 +96,20 @@ function SettingsPage() {
|
||||
|
||||
const upsertMut = useMutation({
|
||||
mutationFn: (patch: SettingsFormValues) => {
|
||||
if (current?.id) return api.update<Settings>('settings', current.id, patch)
|
||||
const { customFieldsJson, ...rest } = patch
|
||||
let customFields: unknown[] = []
|
||||
try {
|
||||
const parsed = JSON.parse(customFieldsJson || '[]') as unknown
|
||||
if (Array.isArray(parsed)) customFields = parsed
|
||||
} catch {
|
||||
throw new ApiError('Невалидный JSON в кастомных полях')
|
||||
}
|
||||
const payload = { ...rest, customFields }
|
||||
if (current?.id) return api.update<Settings>('settings', current.id, payload)
|
||||
return api.create<Settings>('settings', {
|
||||
id: 'settings-main',
|
||||
ratesUrl: 'https://www.cbr-xml-daily.ru/latest.js',
|
||||
...patch,
|
||||
...payload,
|
||||
} as Settings)
|
||||
},
|
||||
onSuccess: () => {
|
||||
@@ -173,6 +192,30 @@ function SettingsPage() {
|
||||
<UploadIcon data-icon="inline-start" />
|
||||
Импорт JSON
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = '.db,application/octet-stream'
|
||||
input.onchange = async () => {
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
try {
|
||||
const buffer = await file.arrayBuffer()
|
||||
await api.importBackupDatabase(buffer)
|
||||
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
||||
toast.success('Импорт SQLite выполнен')
|
||||
} catch (e) {
|
||||
toast.error(e instanceof ApiError ? e.message : 'Ошибка импорта')
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
}}
|
||||
>
|
||||
<UploadIcon data-icon="inline-start" />
|
||||
Импорт SQLite
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -259,6 +302,9 @@ function SettingsPage() {
|
||||
{...form.register('telegramBotToken')}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Thread ID (топик)" htmlFor="set-tg-thread">
|
||||
<Input id="set-tg-thread" placeholder="Необязательно" {...form.register('telegramMessageThreadId')} />
|
||||
</FormField>
|
||||
<LoadingButton
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -319,9 +365,53 @@ function SettingsPage() {
|
||||
<BoolSelect id="set-notify-tar" label="Новые тарифы" value={field.value ?? true} onChange={field.onChange} />
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="notifyVpsDownEnabled"
|
||||
render={({ field }) => (
|
||||
<BoolSelect id="set-notify-down" label="VPS недоступен" value={field.value ?? true} onChange={field.onChange} />
|
||||
)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="md:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Webhook</CardTitle>
|
||||
<CardDescription>POST JSON при тех же событиях, что и Telegram</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FieldGroup>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="webhookEnabled"
|
||||
render={({ field }) => (
|
||||
<BoolSelect id="set-webhook" label="Webhook" value={field.value ?? false} onChange={field.onChange} />
|
||||
)}
|
||||
/>
|
||||
<FormField label="Webhook URL" htmlFor="set-webhook-url" error={form.formState.errors.webhookUrl?.message}>
|
||||
<Input id="set-webhook-url" placeholder="https://hooks.example.com/..." {...form.register('webhookUrl')} />
|
||||
</FormField>
|
||||
</FieldGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="md:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Кастомные поля VPS</CardTitle>
|
||||
<CardDescription>JSON-массив: {"{ \"key\", \"label\", \"type\": \"text|number|bool\" }"}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FormField label="Схема полей" htmlFor="set-custom-fields" error={form.formState.errors.customFieldsJson?.message}>
|
||||
<Textarea
|
||||
id="set-custom-fields"
|
||||
className="min-h-32 font-mono text-xs"
|
||||
{...form.register('customFieldsJson')}
|
||||
/>
|
||||
</FormField>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<LoadingButton
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMemo } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||
import { api, ApiError } from '@/lib/api-client'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@cfdm/ui/components/alert'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
||||
import type { DataTableColumn } from '@/components/data-grid-types'
|
||||
@@ -15,6 +17,7 @@ import { ServerIcon, UserRoundIcon, CpuIcon, CoinsIcon, HardDriveIcon, RefreshCw
|
||||
import type { ActiveTariff } from '@/types/entities'
|
||||
import { providerByIdMap, accountSelectLabel, billmanagerSyncableAccounts } from '@/lib/billmanager'
|
||||
import { formatCurrency } from '@/lib/format'
|
||||
import { computeTariffDiffs } from '@/lib/tariff-diff'
|
||||
|
||||
export const Route = createFileRoute('/_auth/tariffs')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
@@ -30,6 +33,11 @@ function TariffsPage() {
|
||||
? billmanagerSyncableAccounts(snapshot.providerAccounts, snapshot.providers).length
|
||||
: 0
|
||||
|
||||
const tariffDiffs = useMemo(
|
||||
() => (snapshot ? computeTariffDiffs(snapshot.vps, snapshot.activeTariffs) : []),
|
||||
[snapshot],
|
||||
)
|
||||
|
||||
const syncTariffsMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!snapshot) return { tariffsCount: 0 }
|
||||
@@ -143,11 +151,29 @@ function TariffsPage() {
|
||||
}
|
||||
>
|
||||
{(snap) => (
|
||||
<DataGridCard
|
||||
columns={columnDefFromDataTable(columns)}
|
||||
data={snap.activeTariffs}
|
||||
rowId={(t) => t.id}
|
||||
/>
|
||||
<div className="flex flex-col gap-4">
|
||||
{tariffDiffs.length > 0 ? (
|
||||
<Alert>
|
||||
<AlertTitle>Расхождение тариф vs VPS ({tariffDiffs.length})</AlertTitle>
|
||||
<AlertDescription className="flex flex-col gap-1">
|
||||
{tariffDiffs.slice(0, 5).map((d) => (
|
||||
<span key={d.vpsId}>
|
||||
<Link to="/vps/$vpsId" params={{ vpsId: d.vpsId }} className="underline">
|
||||
{d.vpsLabel}
|
||||
</Link>
|
||||
{' '}({d.tariffName}): {d.issues.join('; ')}
|
||||
</span>
|
||||
))}
|
||||
{tariffDiffs.length > 5 ? <span>…и ещё {tariffDiffs.length - 5}</span> : null}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<DataGridCard
|
||||
columns={columnDefFromDataTable(columns)}
|
||||
data={snap.activeTariffs}
|
||||
rowId={(t) => t.id}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CrudListPage>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
GlobeIcon,
|
||||
CpuIcon,
|
||||
CreditCardIcon,
|
||||
RefreshCwIcon,
|
||||
StickyNoteIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { snapshotQueryOptions } from '@/queries/snapshot'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
|
||||
import { DataGridCard, columnDefFromDataTable } from '@/components/data-grid-card'
|
||||
import type { DataTableColumn } from '@/components/data-grid-types'
|
||||
import { getPaidUntilDate } from '@/lib/paid-until'
|
||||
import {
|
||||
effectiveVpsTariffCurrency,
|
||||
formatCurrency,
|
||||
formatInProviderCurrency,
|
||||
tariffTypeLabel,
|
||||
vpsStatusLabel,
|
||||
paymentTypeLabel,
|
||||
} from '@/lib/format'
|
||||
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
|
||||
import { VPS_SYNC_OVERRIDE_FIELDS, parseUserOverrides } from '@/lib/vps-sync-fields'
|
||||
import type { Payment, Vps } from '@/types/entities'
|
||||
|
||||
export const Route = createFileRoute('/_auth/vps/$vpsId')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
queryClient.ensureQueryData(snapshotQueryOptions()),
|
||||
component: VpsDetailPage,
|
||||
})
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 sm:flex-row sm:justify-between sm:gap-4">
|
||||
<span className="text-sm text-muted-foreground">{label}</span>
|
||||
<span className="text-sm font-medium">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function VpsDetailPage() {
|
||||
const { vpsId } = Route.useParams()
|
||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||
|
||||
const vps = snapshot?.vps.find((v) => v.id === vpsId)
|
||||
const providerById = snapshot ? providerByIdMap(snapshot.providers) : new Map()
|
||||
const account = snapshot?.providerAccounts.find((a) => a.id === vps?.providerAccountId)
|
||||
const provider = vps ? providerById.get(vps.providerId) : undefined
|
||||
|
||||
const paidUntil = useMemo(() => {
|
||||
if (!vps || !snapshot) return null
|
||||
return getPaidUntilDate(vps, {
|
||||
vps: snapshot.vps,
|
||||
providerAccounts: snapshot.providerAccounts,
|
||||
payments: snapshot.payments,
|
||||
balanceLedger: snapshot.balanceLedger,
|
||||
now: new Date(),
|
||||
})
|
||||
}, [vps, snapshot])
|
||||
|
||||
const relatedPayments = useMemo(
|
||||
() => (snapshot?.payments ?? []).filter((p) => p.vpsId === vpsId),
|
||||
[snapshot, vpsId],
|
||||
)
|
||||
|
||||
const overrides = vps ? parseUserOverrides((vps as Vps & { userOverrides?: unknown }).userOverrides) : []
|
||||
|
||||
const paymentColumns: DataTableColumn<Payment>[] = [
|
||||
{ key: 'date', header: 'Дата', cell: (p) => <span className="tabular-nums">{p.date}</span> },
|
||||
{ key: 'type', header: 'Тип', cell: (p) => paymentTypeLabel(p.type) },
|
||||
{
|
||||
key: 'amount',
|
||||
header: 'Сумма',
|
||||
className: 'text-right',
|
||||
cell: (p) => <span className="tabular-nums">{formatCurrency(p.amount, p.currency)}</span>,
|
||||
},
|
||||
{ key: 'note', header: 'Заметка', cell: (p) => p.note || '—' },
|
||||
]
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title={vps ? (vps.ip || vps.dns || 'VPS') : 'VPS'}
|
||||
description={account ? accountSelectLabel(account, providerById) : undefined}
|
||||
actions={
|
||||
<Button variant="outline" render={<Link to="/vps" search={{ edit: vpsId }} />}>
|
||||
Редактировать
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Button variant="ghost" size="sm" className="w-fit" render={<Link to="/vps" />}>
|
||||
<ArrowLeftIcon data-icon="inline-start" />
|
||||
К списку VPS
|
||||
</Button>
|
||||
|
||||
<QueryState
|
||||
data={vps}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={() => refetch()}
|
||||
empty={!isLoading && !vps}
|
||||
emptyTitle="VPS не найден"
|
||||
emptyDescription="Запись могла быть удалена"
|
||||
emptyAction={
|
||||
<Button render={<Link to="/vps" />}>К списку</Button>
|
||||
}
|
||||
>
|
||||
{(row) => (
|
||||
<Tabs defaultValue="overview">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Обзор</TabsTrigger>
|
||||
<TabsTrigger value="finance">Финансы</TabsTrigger>
|
||||
<TabsTrigger value="notes">Заметки</TabsTrigger>
|
||||
<TabsTrigger value="sync">Синк</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant={row.status === 'active' ? 'default' : 'secondary'}>
|
||||
{vpsStatusLabel(row.status)}
|
||||
</Badge>
|
||||
{row.project ? <Badge variant="outline">{row.project}</Badge> : null}
|
||||
{row.environment ? <Badge variant="outline">{row.environment}</Badge> : null}
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<GlobeIcon className="size-4" />
|
||||
Сеть
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<InfoRow label="IP" value={row.ip || '—'} />
|
||||
<InfoRow label="DNS" value={row.dns || '—'} />
|
||||
<InfoRow label="IPv6" value={(row as Vps & { ipv6?: string }).ipv6 || '—'} />
|
||||
<InfoRow label="SSH порт" value={(row as Vps & { sshPort?: number }).sshPort ?? 22} />
|
||||
<InfoRow label="Локация" value={[row.country, row.city].filter(Boolean).join(', ') || '—'} />
|
||||
<InfoRow label="Дата-центр" value={row.datacenter || '—'} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<CpuIcon className="size-4" />
|
||||
Ресурсы
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<InfoRow label="vCPU" value={row.vcpu} />
|
||||
<InfoRow label="RAM" value={`${row.ramGb} GB`} />
|
||||
<InfoRow label="Disk" value={`${row.diskGb} GB`} />
|
||||
<InfoRow label="ОС" value={(row as Vps & { os?: string }).os || '—'} />
|
||||
<InfoRow label="Хостер" value={provider?.name || '—'} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="finance" className="flex flex-col gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<CreditCardIcon className="size-4" />
|
||||
Тариф
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<InfoRow label="Тип" value={tariffTypeLabel(row.tariffType)} />
|
||||
<InfoRow
|
||||
label="Ставка"
|
||||
value={formatInProviderCurrency(
|
||||
row.tariffType === 'daily' ? Number(row.dailyRate || 0) * 30 : Number(row.monthlyRate || 0),
|
||||
effectiveVpsTariffCurrency(row, provider),
|
||||
provider,
|
||||
snapshot?.settings ?? [],
|
||||
null,
|
||||
)}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Оплачено до"
|
||||
value={paidUntil ? paidUntil.toLocaleDateString('ru-RU') : '—'}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<DataGridCard
|
||||
title="Связанные платежи"
|
||||
columns={columnDefFromDataTable(paymentColumns)}
|
||||
data={relatedPayments}
|
||||
rowId={(p) => p.id}
|
||||
emptyTitle="Платежей нет"
|
||||
pagination={false}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="notes">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<StickyNoteIcon className="size-4" />
|
||||
Заметки
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="whitespace-pre-wrap text-sm">{row.notes?.trim() || 'Нет заметок'}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sync">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<RefreshCwIcon className="size-4" />
|
||||
Защита от перезаписи при синке
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{overrides.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Все поля обновляются из BILLmanager</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1 text-sm">
|
||||
{overrides.map((key) => {
|
||||
const label = VPS_SYNC_OVERRIDE_FIELDS.find((f) => f.key === key)?.label ?? key
|
||||
return <li key={key}>• {label}</li>
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</QueryState>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
import { createFileRoute, useNavigate, Link } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { PlusIcon, GlobeIcon, UserRoundIcon, FolderKanbanIcon, CpuIcon, CircleDotIcon, CreditCardIcon, MapPinIcon } from 'lucide-react'
|
||||
import { PlusIcon, GlobeIcon, UserRoundIcon, FolderKanbanIcon, CpuIcon, CircleDotIcon, CreditCardIcon, MapPinIcon, CalendarIcon, ActivityIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { snapshotQueryOptions, ratesQueryOptions } from '@/queries/snapshot'
|
||||
import { api, ApiError } from '@/lib/api-client'
|
||||
@@ -27,17 +27,21 @@ import {
|
||||
type VpsFiltersState,
|
||||
} from '@/components/vps-filters'
|
||||
import { VpsFiltersToolbar } from '@/components/vps-filters-toolbar'
|
||||
import { HealthModeBanner } from '@/components/health-mode-banner'
|
||||
import { VpsBulkToolbar } from '@/components/domain/vps-bulk-toolbar'
|
||||
|
||||
import type { Vps } from '@/types/entities'
|
||||
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
|
||||
import { COUNTRIES, COUNTRY_BY_NAME_RU, buildCityOptions } from '@cfdm/shared/geo'
|
||||
import { getPaidUntilDate } from '@/lib/paid-until'
|
||||
import { parseCustomFieldDefs } from '@/lib/custom-fields'
|
||||
|
||||
import { z } from 'zod'
|
||||
|
||||
const vpsSearchSchema = z.object({
|
||||
health: z.string().optional(),
|
||||
edit: z.string().optional(),
|
||||
project: z.string().optional(),
|
||||
})
|
||||
|
||||
export const Route = createFileRoute('/_auth/vps')({
|
||||
@@ -52,18 +56,27 @@ const EMPTY_FORM = VPS_FORM_EMPTY
|
||||
function VpsPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
const { health, edit } = Route.useSearch()
|
||||
const { health, edit, project: projectSearch } = Route.useSearch()
|
||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [defaultValues, setDefaultValues] = useState<VpsFormValues>(EMPTY_FORM)
|
||||
const [filters, setFilters] = useState<VpsFiltersState>(buildDefaultVpsFilters())
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!health) return
|
||||
setFilters((prev) => ({ ...prev, status: prev.status.length ? prev.status : ['active'] }))
|
||||
}, [health])
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectSearch) return
|
||||
setFilters((prev) => ({
|
||||
...prev,
|
||||
project: prev.project.length ? prev.project : [projectSearch],
|
||||
}))
|
||||
}, [projectSearch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!edit || !snapshot) return
|
||||
const row = snapshot.vps.find((v) => v.id === edit)
|
||||
@@ -109,6 +122,17 @@ function VpsPage() {
|
||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка удаления'),
|
||||
})
|
||||
|
||||
const bulkMutation = useMutation({
|
||||
mutationFn: (payload: { ids: string[]; action: string; value?: unknown }) =>
|
||||
api.bulkUpdateVps(payload.ids, payload.action, payload.value),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['snapshot'] })
|
||||
setSelectedIds([])
|
||||
toast.success('Массовое действие выполнено')
|
||||
},
|
||||
onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка'),
|
||||
})
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingId(null)
|
||||
setDefaultValues({
|
||||
@@ -124,10 +148,14 @@ function VpsPage() {
|
||||
setSheetOpen(true)
|
||||
}
|
||||
const submit = (values: VpsFormValues) => {
|
||||
const payload = {
|
||||
...values,
|
||||
customData: JSON.stringify(values.customData ?? {}),
|
||||
} as unknown as Partial<Vps>
|
||||
if (editingId) {
|
||||
void updateMutation.mutate({ id: editingId, patch: values as unknown as Partial<Vps> })
|
||||
void updateMutation.mutate({ id: editingId, patch: payload })
|
||||
} else {
|
||||
void createMutation.mutate(values)
|
||||
void createMutation.mutate(payload as unknown as VpsFormValues)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,7 +270,12 @@ function VpsPage() {
|
||||
header: 'IP / DNS',
|
||||
icon: GlobeIcon,
|
||||
sortValue: (v) => v.ip || v.dns || '',
|
||||
cell: (v) => dataGridCellStack(v.ip || '—', v.dns || undefined),
|
||||
cell: (v) => dataGridCellStack(
|
||||
<Button variant="link" className="h-auto p-0 font-normal" render={<Link to="/vps/$vpsId" params={{ vpsId: v.id }} />}>
|
||||
{v.ip || '—'}
|
||||
</Button>,
|
||||
v.dns || undefined,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'account',
|
||||
@@ -318,6 +351,54 @@ function VpsPage() {
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'health',
|
||||
header: 'Мониторинг',
|
||||
icon: ActivityIcon,
|
||||
sortable: false,
|
||||
cell: (v) => {
|
||||
const ext = v as Vps & { lastHealthStatus?: string; monitoringEnabled?: boolean }
|
||||
if (!ext.monitoringEnabled) return <span className="text-muted-foreground">—</span>
|
||||
if (ext.lastHealthStatus === 'up') return <Badge variant="default">up</Badge>
|
||||
if (ext.lastHealthStatus === 'down') return <Badge variant="destructive">down</Badge>
|
||||
return <Badge variant="outline">—</Badge>
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'paidUntil',
|
||||
header: 'Оплачено до',
|
||||
icon: CalendarIcon,
|
||||
sortValue: (v) => {
|
||||
if (!snapshot) return ''
|
||||
const d = getPaidUntilDate(v, {
|
||||
vps: snapshot.vps,
|
||||
providerAccounts: snapshot.providerAccounts,
|
||||
payments: snapshot.payments,
|
||||
balanceLedger: snapshot.balanceLedger,
|
||||
now: new Date(),
|
||||
})
|
||||
return d?.getTime() ?? 0
|
||||
},
|
||||
cell: (v) => {
|
||||
if (!snapshot) return '—'
|
||||
const d = getPaidUntilDate(v, {
|
||||
vps: snapshot.vps,
|
||||
providerAccounts: snapshot.providerAccounts,
|
||||
payments: snapshot.payments,
|
||||
balanceLedger: snapshot.balanceLedger,
|
||||
now: new Date(),
|
||||
})
|
||||
if (!d) return <span className="text-muted-foreground">—</span>
|
||||
const todayStart = new Date()
|
||||
todayStart.setHours(0, 0, 0, 0)
|
||||
const overdue = d < todayStart
|
||||
return (
|
||||
<span className={overdue ? 'text-destructive tabular-nums' : 'tabular-nums text-muted-foreground'}>
|
||||
{d.toLocaleDateString('ru-RU')}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
@@ -396,6 +477,15 @@ function VpsPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{health ? <HealthModeBanner health={health} exitTo="/vps" /> : null}
|
||||
<VpsBulkToolbar
|
||||
selectedCount={selectedIds.length}
|
||||
projectOptions={projectNameOptions}
|
||||
busy={bulkMutation.isPending}
|
||||
onSetStatus={(status) => bulkMutation.mutate({ ids: selectedIds, action: 'status', value: status })}
|
||||
onSetProject={(project) => bulkMutation.mutate({ ids: selectedIds, action: 'project', value: project })}
|
||||
onDelete={() => bulkMutation.mutate({ ids: selectedIds, action: 'delete' })}
|
||||
/>
|
||||
<VpsFiltersToolbar
|
||||
filters={filters}
|
||||
onChange={setFilters}
|
||||
@@ -415,6 +505,8 @@ function VpsPage() {
|
||||
rowId={(v) => v.id}
|
||||
emptyTitle="VPS не найдены"
|
||||
pinLastColumn
|
||||
enableRowSelection
|
||||
onRowSelectionChange={setSelectedIds}
|
||||
virtualization={section.items.length > 200}
|
||||
height={560}
|
||||
/>
|
||||
@@ -434,6 +526,9 @@ function VpsPage() {
|
||||
providerAccounts={snapshot.providerAccounts}
|
||||
vpsRows={snapshot.vps}
|
||||
formCountryOptions={formCountryOptions}
|
||||
customFieldDefs={parseCustomFieldDefs(
|
||||
(snapshot.settings[0] as { customFields?: unknown })?.customFields,
|
||||
)}
|
||||
onSubmit={submit}
|
||||
submitting={createMutation.isPending || updateMutation.isPending}
|
||||
/>
|
||||
|
||||
Reference in new issue
Block a user