Enhance user management and node health check features
CI / Backend (build + test) (push) Failing after 2m14s
CI / Frontend (lint + typecheck + build) (push) Successful in 51s

- Updated `ListUsersQueryHandler` to include plan names and config quotas in `UserSummaryDto`, enriching user data retrieval.
- Implemented `WithPlanNamesAsync` method to fetch plan names based on user plan IDs, improving user experience in the admin interface.
- Enhanced `Node` class with a `ConsecutiveProbeFailures` property for better status management during health checks.
- Modified `NodeHealthCheckService` to utilize the new `RecordProbe` method, implementing a hysteresis mechanism for node status changes.
- Updated frontend components to display user config quotas and plan names, improving clarity in user management.
- Enhanced tests for user listing and node status handling to ensure robust functionality and coverage.
- Updated documentation to reflect changes in user and node management features.
This commit is contained in:
Leonid Pershin
2026-08-05 08:34:17 +03:00
parent c2ed3240bd
commit 4b34c37ce3
20 changed files with 1563 additions and 28 deletions
@@ -9,9 +9,11 @@ import { Label } from '@/shared/ui/label'
import { Badge } from '@/shared/ui/badge'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { listRoles } from '@/features/admin/roles/api'
import { listAdminPlans } from '@/features/admin/plans/api'
import { grantBillingGift } from '@/features/admin/billing/api'
import { PaidUntilBadge } from '@/features/billing/PaidUntilBadge'
import { useAuthStore } from '@/features/auth/store'
import { HttpError } from '@/shared/api/client'
import type { UserSummaryDto } from '@/shared/api/types'
import {
blockUser,
@@ -20,6 +22,7 @@ import {
forceRevokeConfig,
getUserConfigs,
resetUserPassword,
setUserPlan,
unblockUser,
} from './api'
@@ -28,9 +31,11 @@ export function UserManageDialog({ user, open, onOpenChange }: { user: UserSumma
const queryClient = useQueryClient()
const [newPassword, setNewPassword] = useState('')
const [giftDays, setGiftDays] = useState('')
const [customConfigCount, setCustomConfigCount] = useState('')
const currentUserId = useAuthStore((state) => state.user?.id)
const rolesQuery = useQuery({ queryKey: ['admin-roles'], queryFn: listRoles, enabled: open })
const plansQuery = useQuery({ queryKey: ['admin-plans'], queryFn: listAdminPlans, enabled: open })
const configsQuery = useQuery({ queryKey: ['admin-user-configs', user.id], queryFn: () => getUserConfigs(user.id), enabled: open })
const invalidateUsers = () => queryClient.invalidateQueries({ queryKey: ['admin-users'] })
@@ -53,6 +58,16 @@ export function UserManageDialog({ user, open, onOpenChange }: { user: UserSumma
onError: () => toast.error(t('auth.genericError')),
})
const planMutation = useMutation({
mutationFn: (plan: { planId: string } | { customConfigCount: number }) => setUserPlan(user.id, plan),
onSuccess: async () => {
toast.success(t('admin.users.quotaChanged'))
setCustomConfigCount('')
await invalidateUsers()
},
onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')),
})
const resetPasswordMutation = useMutation({
mutationFn: () => resetUserPassword(user.id, newPassword),
onSuccess: () => {
@@ -132,6 +147,51 @@ export function UserManageDialog({ user, open, onOpenChange }: { user: UserSumma
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label>{t('admin.users.configQuota')}</Label>
<p className="text-sm text-muted-foreground">
{t('admin.users.quotaCurrent', {
quota: user.configQuota === -1 ? '∞' : user.configQuota,
plan:
user.configQuota === -1
? t('admin.users.unlimitedQuota')
: (user.planName ?? t('admin.users.customQuota')),
})}
</p>
<Select value={user.planId ?? ''} onValueChange={(planId) => planMutation.mutate({ planId })}>
<SelectTrigger>
<SelectValue placeholder={t('admin.users.selectPlan')} />
</SelectTrigger>
<SelectContent>
{plansQuery.data?.map((plan) => (
<SelectItem key={plan.id} value={plan.id}>
{plan.name} · {t('admin.users.planConfigCount', { count: plan.configCount })}
{/* Отключённые тарифы скрыты от пользователей, но админу назначить их можно —
помечаем, чтобы это было осознанным выбором, а не случайным. */}
{plan.isEnabled ? '' : ` · ${t('admin.users.planDisabled')}`}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex gap-2">
<Input
type="number"
min={1}
value={customConfigCount}
onChange={(e) => setCustomConfigCount(e.target.value)}
placeholder={t('admin.users.customQuotaPlaceholder')}
/>
<Button
variant="outline"
disabled={!(Number(customConfigCount) > 0) || planMutation.isPending}
onClick={() => planMutation.mutate({ customConfigCount: Number(customConfigCount) })}
>
{t('admin.users.applyQuota')}
</Button>
</div>
<p className="text-xs text-muted-foreground">{t('admin.users.quotaHint')}</p>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="newPassword">{t('admin.users.resetPassword')}</Label>
<div className="flex gap-2">
+13
View File
@@ -43,6 +43,19 @@ export function changeUserRole(id: string, roleId: string) {
return apiRequest<void>(`/admin/users/${id}/role`, { method: 'PATCH', body: { roleId } })
}
/** Админский оверрайд тарифа/квоты: доплата не создаётся и лишние конфиги при понижении не
* отзываются (грандфазеринг) — в отличие от самообслуживания `POST /api/plans/change`.
* Передаётся ровно одно из двух: каталожный тариф либо произвольное число конфигов. */
export function setUserPlan(id: string, plan: { planId: string } | { customConfigCount: number }) {
return apiRequest<void>(`/admin/users/${id}/plan`, {
method: 'PATCH',
body: {
planId: 'planId' in plan ? plan.planId : null,
customConfigCount: 'customConfigCount' in plan ? plan.customConfigCount : null,
},
})
}
export function deleteUser(id: string) {
return apiRequest<void>(`/admin/users/${id}`, { method: 'DELETE' })
}
+9
View File
@@ -130,6 +130,7 @@ function AdminUsersPage() {
<th className="py-2 font-medium">{t('admin.users.userName')}</th>
<th className="py-2 font-medium">{t('admin.users.role')}</th>
<th className="py-2 font-medium">{t('admin.users.statusLabel')}</th>
<th className="py-2 font-medium">{t('admin.users.configQuota')}</th>
<th className="py-2 font-medium">{t('admin.users.billingLabel')}</th>
<th className="py-2" />
</tr>
@@ -148,6 +149,14 @@ function AdminUsersPage() {
: t('admin.users.status.pending')}
</Badge>
</td>
<td className="py-2">
<span>{user.configQuota === -1 ? '∞' : user.configQuota}</span>
<span className="ml-1 text-xs text-muted-foreground">
{user.configQuota === -1
? t('admin.users.unlimitedQuota')
: (user.planName ?? t('admin.users.customQuota'))}
</span>
</td>
<td className="py-2">
{user.billingEnabled ? (
<PaidUntilBadge
+6
View File
@@ -177,9 +177,15 @@ export type UserSummaryDto = {
activatedAt: string | null
billingEnabled: boolean
billingPaidUntil: string | null
/** Квота конфигов пользователя; -1 = без лимита (только admin). */
configQuota: number
/** Выбранный каталожный тариф; null — квота задана вручную. */
planId: string | null
/** Есть Subscription-заявка на оплату, ожидающая решения админа — конфиги не гасятся, пока он не
* решит (см. BillingService). Бейдж должен показывать нейтральный статус, а не тревожный "Истекло". */
billingPendingReview: boolean
/** Имя тарифа по planId; null для кастомной квоты. */
planName: string | null
}
export type RoleDto = {
+22
View File
@@ -307,6 +307,17 @@ const resources = {
giftDaysPlaceholder: 'Дней',
giftGrant: 'Подарить',
giftGranted: 'Дни подписки подарены пользователю.',
configQuota: 'Конфигов',
customQuota: 'своя квота',
unlimitedQuota: 'без лимита',
planDisabled: 'отключён',
customQuotaPlaceholder: 'Своё число конфигов',
selectPlan: 'Выбрать тариф',
planConfigCount: 'конфигов: {{count}}',
quotaCurrent: 'Сейчас: {{quota}} — {{plan}}',
applyQuota: 'Применить',
quotaChanged: 'Квота конфигов изменена.',
quotaHint: 'Смена админом — без доплаты; при понижении уже созданные конфиги не отзываются, новые не создать до входа в квоту.',
},
configs: {
searchPlaceholder: 'Поиск по email в панели или метке',
@@ -907,6 +918,17 @@ const resources = {
giftDaysPlaceholder: 'Days',
giftGrant: 'Grant',
giftGranted: 'Subscription days granted to the user.',
configQuota: 'Configs',
customQuota: 'custom quota',
unlimitedQuota: 'unlimited',
planDisabled: 'disabled',
customQuotaPlaceholder: 'Custom config count',
selectPlan: 'Select plan',
planConfigCount: 'configs: {{count}}',
quotaCurrent: 'Current: {{quota}} — {{plan}}',
applyQuota: 'Apply',
quotaChanged: 'Config quota updated.',
quotaHint: 'Admin change is free of charge; on downgrade existing configs are kept, new ones are blocked until the user is within quota.',
},
configs: {
searchPlaceholder: 'Search by panel email or label',