feat(web): клик по проекту, копирование IP и доступ к серверу
Docker / build (push) Failing after 27s
Docker / build (push) Failing after 27s
Исправить выбор пространства через SelectField; режимы share/assign и отзыв grant. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -1,16 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Label } from '@cfdm/ui/components/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@cfdm/ui/components/select'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@@ -19,12 +11,21 @@ import {
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@cfdm/ui/components/sheet'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
|
||||
import { Badge } from '@cfdm/ui/components/badge'
|
||||
|
||||
import { api } from '@/lib/api-client'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { FormField } from '@/components/form-field'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { SelectField } from '@/components/select-field'
|
||||
import { api, type SpaceVpsGrant } from '@/lib/api-client'
|
||||
import { copyText } from '@/lib/clipboard'
|
||||
import { useSpaceId } from '@/lib/space'
|
||||
import { spacesQueryOptions, snapshotKeys } from '@/queries/snapshot'
|
||||
import { spacesKeys, spacesQueryOptions, snapshotKeys } from '@/queries/snapshot'
|
||||
import type { Vps } from '@/types/entities'
|
||||
|
||||
type AccessMode = 'share' | 'assign'
|
||||
|
||||
type Props = {
|
||||
vps: Vps | null
|
||||
open: boolean
|
||||
@@ -36,100 +37,246 @@ export function VpsAccessSheet({ vps, open, onOpenChange }: Props) {
|
||||
const { spaceId } = useSpaceId()
|
||||
const { data: spaces = [] } = useQuery(spacesQueryOptions())
|
||||
const fromSpaceId = spaceId ?? spaces.find((s) => s.kind === 'main')?.id ?? ''
|
||||
const targets = spaces.filter((s) => s.id !== fromSpaceId)
|
||||
const [toSpaceId, setToSpaceId] = useState('')
|
||||
const targets = useMemo(
|
||||
() => spaces.filter((s) => s.id !== fromSpaceId && !s.deletedAt),
|
||||
[spaces, fromSpaceId],
|
||||
)
|
||||
const spaceNameById = useMemo(
|
||||
() => new Map(spaces.map((s) => [s.id, s.name])),
|
||||
[spaces],
|
||||
)
|
||||
|
||||
const [mode, setMode] = useState<AccessMode>('share')
|
||||
const [toSpaceId, setToSpaceId] = useState<string | null>(null)
|
||||
const [permission, setPermission] = useState<'read' | 'write'>('read')
|
||||
const [assignConfirmOpen, setAssignConfirmOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setMode('share')
|
||||
setToSpaceId(null)
|
||||
setPermission('read')
|
||||
setAssignConfirmOpen(false)
|
||||
}, [open, vps?.id])
|
||||
|
||||
const grantsQuery = useQuery({
|
||||
queryKey: [...spacesKeys.all, fromSpaceId, 'vps-grants', vps?.id],
|
||||
queryFn: () => api.fetchSpaceGrants(fromSpaceId),
|
||||
enabled: open && Boolean(fromSpaceId) && Boolean(vps?.id),
|
||||
})
|
||||
|
||||
const outgoingForVps = useMemo(() => {
|
||||
const outgoing = (grantsQuery.data?.outgoing ?? []) as SpaceVpsGrant[]
|
||||
if (!vps?.id) return []
|
||||
return outgoing.filter((g) => g.vpsId === vps.id)
|
||||
}, [grantsQuery.data, vps?.id])
|
||||
|
||||
const spaceOptions = useMemo(
|
||||
() => targets.map((s) => ({ value: s.id, label: s.name })),
|
||||
[targets],
|
||||
)
|
||||
|
||||
const permissionOptions = useMemo(
|
||||
() => [
|
||||
{ value: 'read', label: 'Чтение' },
|
||||
{ value: 'write', label: 'Запись' },
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const invalidate = async () => {
|
||||
await Promise.all([
|
||||
qc.invalidateQueries({ queryKey: snapshotKeys.all }),
|
||||
qc.invalidateQueries({ queryKey: spacesKeys.all }),
|
||||
])
|
||||
}
|
||||
|
||||
const shareMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
api.shareVps(fromSpaceId, vps!.id, {
|
||||
toSpaceId,
|
||||
toSpaceId: toSpaceId!,
|
||||
permission,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
toast.success('Доступ выдан (share)')
|
||||
toast.success('Доступ выдан')
|
||||
onOpenChange(false)
|
||||
await qc.invalidateQueries({ queryKey: snapshotKeys.all })
|
||||
await invalidate()
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const assignMutation = useMutation({
|
||||
mutationFn: () => api.assignVps(fromSpaceId, vps!.id, toSpaceId),
|
||||
mutationFn: () => api.assignVps(fromSpaceId, vps!.id, toSpaceId!),
|
||||
onSuccess: async () => {
|
||||
toast.success('Сервер перенесён (assign)')
|
||||
toast.success('Сервер перенесён')
|
||||
onOpenChange(false)
|
||||
await qc.invalidateQueries({ queryKey: snapshotKeys.all })
|
||||
await invalidate()
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: (grantId: string) => api.revokeVpsGrant(fromSpaceId, grantId),
|
||||
onSuccess: async () => {
|
||||
toast.success('Доступ отозван')
|
||||
await invalidate()
|
||||
await grantsQuery.refetch()
|
||||
},
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
})
|
||||
|
||||
const vpsLabel = vps?.ip || vps?.dns || vps?.id || ''
|
||||
const canSubmit = Boolean(toSpaceId && vps && fromSpaceId && targets.length > 0)
|
||||
const busy = shareMutation.isPending || assignMutation.isPending
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="flex flex-col gap-4 sm:max-w-md">
|
||||
<SheetHeader>
|
||||
<SheetContent side="right" className="flex w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-md">
|
||||
<SheetHeader className="shrink-0 border-b border-border/50">
|
||||
<SheetTitle>Доступ к серверу</SheetTitle>
|
||||
<SheetDescription>
|
||||
{vps ? `${vps.ip || vps.dns || vps.id}` : ''}
|
||||
{' — share оставляет запись здесь; assign переносит в другое пространство.'}
|
||||
<SheetDescription className="flex flex-col gap-1">
|
||||
{vps?.ip ? (
|
||||
<button
|
||||
type="button"
|
||||
className="text-foreground w-fit font-medium underline-offset-4 hover:underline"
|
||||
onClick={() => void copyText(vps.ip, 'IP скопирован')}
|
||||
>
|
||||
{vps.ip}
|
||||
</button>
|
||||
) : (
|
||||
<span>{vpsLabel}</span>
|
||||
)}
|
||||
<span>
|
||||
{mode === 'share'
|
||||
? 'Поделиться — сервер остаётся здесь, в целевом пространстве появится доступ.'
|
||||
: 'Перенести — сервер уйдёт в другое пространство; привязка к аккаунту провайдера сбросится.'}
|
||||
</span>
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Целевое пространство</Label>
|
||||
<Select value={toSpaceId} onValueChange={(v) => setToSpaceId(v ?? '')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Выберите пространство" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targets.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4">
|
||||
<Tabs
|
||||
value={mode}
|
||||
onValueChange={(v) => setMode((v as AccessMode) ?? 'share')}
|
||||
>
|
||||
<TabsList variant="line" className="gap-5">
|
||||
<TabsTrigger
|
||||
value="share"
|
||||
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
|
||||
>
|
||||
Поделиться
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="assign"
|
||||
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
|
||||
>
|
||||
Перенести
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{targets.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Нет других пространств. Создайте пространство, чтобы поделиться или перенести сервер.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
<FormField label="Целевое пространство" htmlFor="access-to-space">
|
||||
<SelectField
|
||||
triggerId="access-to-space"
|
||||
options={spaceOptions}
|
||||
value={toSpaceId}
|
||||
onValueChange={setToSpaceId}
|
||||
placeholder="Выберите пространство"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{mode === 'share' ? (
|
||||
<FormField
|
||||
label="Права"
|
||||
htmlFor="access-permission"
|
||||
description="Чтение — только просмотр; запись — можно редактировать"
|
||||
>
|
||||
<SelectField
|
||||
triggerId="access-permission"
|
||||
options={permissionOptions}
|
||||
value={permission}
|
||||
onValueChange={(v) => setPermission((v as 'read' | 'write') ?? 'read')}
|
||||
/>
|
||||
</FormField>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-semibold">Текущие доступы</h3>
|
||||
{grantsQuery.isLoading ? (
|
||||
<p className="text-muted-foreground text-sm">Загрузка…</p>
|
||||
) : outgoingForVps.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">Нет выданных доступов для этого сервера</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{outgoingForVps.map((grant) => (
|
||||
<li
|
||||
key={grant.id}
|
||||
className="border-border/50 flex items-center justify-between gap-2 rounded-lg border px-3 py-2"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="truncate text-sm font-medium">
|
||||
{spaceNameById.get(grant.toSpaceId) ?? grant.toSpaceId}
|
||||
</span>
|
||||
<Badge variant="secondary" className="w-fit">
|
||||
{grant.permission === 'write' ? 'Запись' : 'Чтение'}
|
||||
</Badge>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={revokeMutation.isPending}
|
||||
onClick={() => revokeMutation.mutate(grant.id)}
|
||||
>
|
||||
Отозвать
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Права (для share)</Label>
|
||||
<Select
|
||||
value={permission}
|
||||
onValueChange={(v) => setPermission((v as 'read' | 'write') ?? 'read')}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="read">read</SelectItem>
|
||||
<SelectItem value="write">write</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="flex-col gap-2 sm:flex-col">
|
||||
<Button
|
||||
disabled={!toSpaceId || !vps || shareMutation.isPending}
|
||||
onClick={() => shareMutation.mutate()}
|
||||
>
|
||||
Share (ACL)
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!toSpaceId || !vps || assignMutation.isPending}
|
||||
onClick={() => {
|
||||
if (
|
||||
!window.confirm(
|
||||
'Перенести сервер? Привязка к аккаунту провайдера будет сброшена.',
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
assignMutation.mutate()
|
||||
}}
|
||||
>
|
||||
Assign (перенос)
|
||||
</Button>
|
||||
<SheetFooter className="shrink-0 border-t border-border/50 sm:flex-col">
|
||||
{mode === 'share' ? (
|
||||
<LoadingButton
|
||||
type="button"
|
||||
loading={shareMutation.isPending}
|
||||
disabled={!canSubmit || busy}
|
||||
onClick={() => shareMutation.mutate()}
|
||||
>
|
||||
Поделиться
|
||||
</LoadingButton>
|
||||
) : (
|
||||
<>
|
||||
<LoadingButton
|
||||
type="button"
|
||||
variant="outline"
|
||||
loading={assignMutation.isPending}
|
||||
disabled={!canSubmit || busy}
|
||||
onClick={() => setAssignConfirmOpen(true)}
|
||||
>
|
||||
Перенести
|
||||
</LoadingButton>
|
||||
<ConfirmDialog
|
||||
open={assignConfirmOpen}
|
||||
onOpenChange={setAssignConfirmOpen}
|
||||
title="Перенести сервер?"
|
||||
description="Привязка к аккаунту провайдера будет сброшена. Все выданные доступы (share) для этого сервера будут отозваны."
|
||||
confirmLabel="Перенести"
|
||||
destructive
|
||||
onConfirm={() => assignMutation.mutate()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
@@ -30,6 +30,16 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export type SpaceVpsGrant = {
|
||||
id: string
|
||||
vpsId: string
|
||||
fromSpaceId: string
|
||||
toSpaceId: string
|
||||
permission: 'read' | 'write' | string
|
||||
grantedByUserId?: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
async function handoffOnUnauthorized(): Promise<void> {
|
||||
clearToken()
|
||||
const cfg = await ensureAuthConfig()
|
||||
@@ -296,10 +306,16 @@ export const api = {
|
||||
|
||||
fetchSpaceGrants: (spaceId: string) =>
|
||||
fetchApi<{
|
||||
incoming: unknown[]
|
||||
outgoing: unknown[]
|
||||
incoming: SpaceVpsGrant[]
|
||||
outgoing: SpaceVpsGrant[]
|
||||
}>(`/api/spaces/${encodeURIComponent(spaceId)}/vps-grants`),
|
||||
|
||||
revokeVpsGrant: (spaceId: string, grantId: string) =>
|
||||
fetchApi<void>(
|
||||
`/api/spaces/${encodeURIComponent(spaceId)}/vps-grants/${encodeURIComponent(grantId)}`,
|
||||
{ method: 'DELETE' },
|
||||
),
|
||||
|
||||
importBackupJson: (payload: unknown) =>
|
||||
fetchApi('/api/backup/json', { method: 'POST', body: JSON.stringify(payload) }),
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { toast } from 'sonner'
|
||||
|
||||
/** Copy text to clipboard and show a success toast. */
|
||||
export async function copyText(value: string, successMessage = 'Скопировано'): Promise<boolean> {
|
||||
const text = value.trim()
|
||||
if (!text) return false
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
toast.success(successMessage)
|
||||
return true
|
||||
} catch {
|
||||
toast.error('Не удалось скопировать')
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -25,8 +25,15 @@ import { StatusBadge } from '@/components/status-badge'
|
||||
import { ProjectEditSheet, projectFormDefaults } from '@/components/domain/project-edit-sheet'
|
||||
import type { ProjectFormValues } from '@/lib/schemas'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@cfdm/ui/components/card'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||
import { copyText } from '@/lib/clipboard'
|
||||
import { KpiStatGridSkeleton, TableSkeleton } from '@/components/skeletons'
|
||||
import {
|
||||
formatCurrency,
|
||||
@@ -129,15 +136,31 @@ function ProjectDetailPage() {
|
||||
const columns: DataGridColumn<Vps>[] = [
|
||||
{
|
||||
key: 'ip',
|
||||
header: 'IP',
|
||||
header: 'IP / DNS',
|
||||
cell: (v) => (
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 font-medium"
|
||||
render={<Link to="/vps/$vpsId" params={{ vpsId: v.id }} />}
|
||||
>
|
||||
{v.ip || v.id}
|
||||
</Button>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{v.ip ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
className="h-auto justify-start p-0 font-medium"
|
||||
onClick={() => void copyText(v.ip, 'IP скопирован')}
|
||||
>
|
||||
{v.ip}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
{v.dns ? (
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-muted-foreground h-auto justify-start p-0 text-xs font-normal"
|
||||
render={<Link to="/vps/$vpsId" params={{ vpsId: v.id }} />}
|
||||
>
|
||||
{v.dns}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -292,14 +315,15 @@ function ProjectDetailPage() {
|
||||
]}
|
||||
/>
|
||||
{project.notes?.trim() ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Заметки</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Frame spacing="sm" className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Заметки</FrameTitle>
|
||||
<FrameDescription>Комментарии к проекту</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
<p className="text-sm whitespace-pre-wrap">{project.notes}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
) : null}
|
||||
<ResourcePage
|
||||
title="VPS проекта"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
@@ -44,6 +44,7 @@ export const Route = createFileRoute('/_auth/projects')({
|
||||
})
|
||||
|
||||
function ProjectsPage() {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||
const settings = snapshot?.settings?.[0]
|
||||
@@ -133,7 +134,7 @@ function ProjectsPage() {
|
||||
header: 'Проект',
|
||||
icon: FolderKanbanIcon,
|
||||
cell: (row) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<ProjectColorDot color={row.color} />
|
||||
<Button
|
||||
variant="link"
|
||||
@@ -184,18 +185,20 @@ function ProjectsPage() {
|
||||
sortable: false,
|
||||
className: 'w-24 text-right',
|
||||
cell: (row) => (
|
||||
<RowActions
|
||||
onEdit={() => openEdit(row)}
|
||||
onDelete={() => {
|
||||
if (row.vpsTotal > 0) {
|
||||
toast.error(`Нельзя удалить: к проекту привязано ${row.vpsTotal} VPS`)
|
||||
return
|
||||
}
|
||||
delMut.mutate(row.id)
|
||||
}}
|
||||
deleteTitle="Удалить проект?"
|
||||
deleteDescription={`«${row.name}» будет удалён.`}
|
||||
/>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<RowActions
|
||||
onEdit={() => openEdit(row)}
|
||||
onDelete={() => {
|
||||
if (row.vpsTotal > 0) {
|
||||
toast.error(`Нельзя удалить: к проекту привязано ${row.vpsTotal} VPS`)
|
||||
return
|
||||
}
|
||||
delMut.mutate(row.id)
|
||||
}}
|
||||
deleteTitle="Удалить проект?"
|
||||
deleteDescription={`«${row.name}» будет удалён.`}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -296,6 +299,12 @@ function ProjectsPage() {
|
||||
data={rows}
|
||||
getRowId={(r) => r.id}
|
||||
pinLastColumn
|
||||
onRowClick={(row) =>
|
||||
void navigate({
|
||||
to: '/projects/$projectId',
|
||||
params: { projectId: row.id },
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,7 @@ import { ProjectColorDot } from '@/components/project-color-dot'
|
||||
import { VpsBulkToolbar } from '@/components/domain/vps-bulk-toolbar'
|
||||
import { VpsDomainsCell, UnmatchedDomainsBanner } from '@/components/integrations/vps-domains-cell'
|
||||
import { VpsAccessSheet } from '@/components/vps-access-sheet'
|
||||
import { copyText } from '@/lib/clipboard'
|
||||
|
||||
import type { Vps } from '@/types/entities'
|
||||
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
|
||||
@@ -343,12 +344,30 @@ function VpsPage() {
|
||||
header: 'IP / DNS',
|
||||
icon: GlobeIcon,
|
||||
sortValue: (v) => v.ip || v.dns || '',
|
||||
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,
|
||||
),
|
||||
cell: (v) =>
|
||||
dataGridCellStack(
|
||||
v.ip ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
className="h-auto p-0 font-normal"
|
||||
onClick={() => void copyText(v.ip, 'IP скопирован')}
|
||||
>
|
||||
{v.ip}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
),
|
||||
v.dns ? (
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-muted-foreground h-auto p-0 text-xs font-normal"
|
||||
render={<Link to="/vps/$vpsId" params={{ vpsId: v.id }} />}
|
||||
>
|
||||
{v.dns}
|
||||
</Button>
|
||||
) : undefined,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'domains',
|
||||
|
||||
Reference in New Issue
Block a user