Enhance user management and node health check features
- 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:
@@ -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">
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user