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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useState } from 'react'
|
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
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 {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
SheetContent,
|
SheetContent,
|
||||||
@@ -19,12 +11,21 @@ import {
|
|||||||
SheetHeader,
|
SheetHeader,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from '@cfdm/ui/components/sheet'
|
} 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 { useSpaceId } from '@/lib/space'
|
||||||
import { spacesQueryOptions, snapshotKeys } from '@/queries/snapshot'
|
import { spacesKeys, spacesQueryOptions, snapshotKeys } from '@/queries/snapshot'
|
||||||
import type { Vps } from '@/types/entities'
|
import type { Vps } from '@/types/entities'
|
||||||
|
|
||||||
|
type AccessMode = 'share' | 'assign'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
vps: Vps | null
|
vps: Vps | null
|
||||||
open: boolean
|
open: boolean
|
||||||
@@ -36,100 +37,246 @@ export function VpsAccessSheet({ vps, open, onOpenChange }: Props) {
|
|||||||
const { spaceId } = useSpaceId()
|
const { spaceId } = useSpaceId()
|
||||||
const { data: spaces = [] } = useQuery(spacesQueryOptions())
|
const { data: spaces = [] } = useQuery(spacesQueryOptions())
|
||||||
const fromSpaceId = spaceId ?? spaces.find((s) => s.kind === 'main')?.id ?? ''
|
const fromSpaceId = spaceId ?? spaces.find((s) => s.kind === 'main')?.id ?? ''
|
||||||
const targets = spaces.filter((s) => s.id !== fromSpaceId)
|
const targets = useMemo(
|
||||||
const [toSpaceId, setToSpaceId] = useState('')
|
() => 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 [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({
|
const shareMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
api.shareVps(fromSpaceId, vps!.id, {
|
api.shareVps(fromSpaceId, vps!.id, {
|
||||||
toSpaceId,
|
toSpaceId: toSpaceId!,
|
||||||
permission,
|
permission,
|
||||||
}),
|
}),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
toast.success('Доступ выдан (share)')
|
toast.success('Доступ выдан')
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
await qc.invalidateQueries({ queryKey: snapshotKeys.all })
|
await invalidate()
|
||||||
},
|
},
|
||||||
onError: (e: Error) => toast.error(e.message),
|
onError: (e: Error) => toast.error(e.message),
|
||||||
})
|
})
|
||||||
|
|
||||||
const assignMutation = useMutation({
|
const assignMutation = useMutation({
|
||||||
mutationFn: () => api.assignVps(fromSpaceId, vps!.id, toSpaceId),
|
mutationFn: () => api.assignVps(fromSpaceId, vps!.id, toSpaceId!),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
toast.success('Сервер перенесён (assign)')
|
toast.success('Сервер перенесён')
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
await qc.invalidateQueries({ queryKey: snapshotKeys.all })
|
await invalidate()
|
||||||
},
|
},
|
||||||
onError: (e: Error) => toast.error(e.message),
|
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 (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
<SheetContent className="flex flex-col gap-4 sm:max-w-md">
|
<SheetContent side="right" className="flex w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-md">
|
||||||
<SheetHeader>
|
<SheetHeader className="shrink-0 border-b border-border/50">
|
||||||
<SheetTitle>Доступ к серверу</SheetTitle>
|
<SheetTitle>Доступ к серверу</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription className="flex flex-col gap-1">
|
||||||
{vps ? `${vps.ip || vps.dns || vps.id}` : ''}
|
{vps?.ip ? (
|
||||||
{' — share оставляет запись здесь; assign переносит в другое пространство.'}
|
<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>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4">
|
||||||
<Label>Целевое пространство</Label>
|
<Tabs
|
||||||
<Select value={toSpaceId} onValueChange={(v) => setToSpaceId(v ?? '')}>
|
value={mode}
|
||||||
<SelectTrigger>
|
onValueChange={(v) => setMode((v as AccessMode) ?? 'share')}
|
||||||
<SelectValue placeholder="Выберите пространство" />
|
>
|
||||||
</SelectTrigger>
|
<TabsList variant="line" className="gap-5">
|
||||||
<SelectContent>
|
<TabsTrigger
|
||||||
{targets.map((s) => (
|
value="share"
|
||||||
<SelectItem key={s.id} value={s.id}>
|
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
|
||||||
{s.name}
|
>
|
||||||
</SelectItem>
|
Поделиться
|
||||||
))}
|
</TabsTrigger>
|
||||||
</SelectContent>
|
<TabsTrigger
|
||||||
</Select>
|
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>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<SheetFooter className="shrink-0 border-t border-border/50 sm:flex-col">
|
||||||
<Label>Права (для share)</Label>
|
{mode === 'share' ? (
|
||||||
<Select
|
<LoadingButton
|
||||||
value={permission}
|
type="button"
|
||||||
onValueChange={(v) => setPermission((v as 'read' | 'write') ?? 'read')}
|
loading={shareMutation.isPending}
|
||||||
>
|
disabled={!canSubmit || busy}
|
||||||
<SelectTrigger>
|
onClick={() => shareMutation.mutate()}
|
||||||
<SelectValue />
|
>
|
||||||
</SelectTrigger>
|
Поделиться
|
||||||
<SelectContent>
|
</LoadingButton>
|
||||||
<SelectItem value="read">read</SelectItem>
|
) : (
|
||||||
<SelectItem value="write">write</SelectItem>
|
<>
|
||||||
</SelectContent>
|
<LoadingButton
|
||||||
</Select>
|
type="button"
|
||||||
</div>
|
variant="outline"
|
||||||
|
loading={assignMutation.isPending}
|
||||||
<SheetFooter className="flex-col gap-2 sm:flex-col">
|
disabled={!canSubmit || busy}
|
||||||
<Button
|
onClick={() => setAssignConfirmOpen(true)}
|
||||||
disabled={!toSpaceId || !vps || shareMutation.isPending}
|
>
|
||||||
onClick={() => shareMutation.mutate()}
|
Перенести
|
||||||
>
|
</LoadingButton>
|
||||||
Share (ACL)
|
<ConfirmDialog
|
||||||
</Button>
|
open={assignConfirmOpen}
|
||||||
<Button
|
onOpenChange={setAssignConfirmOpen}
|
||||||
variant="outline"
|
title="Перенести сервер?"
|
||||||
disabled={!toSpaceId || !vps || assignMutation.isPending}
|
description="Привязка к аккаунту провайдера будет сброшена. Все выданные доступы (share) для этого сервера будут отозваны."
|
||||||
onClick={() => {
|
confirmLabel="Перенести"
|
||||||
if (
|
destructive
|
||||||
!window.confirm(
|
onConfirm={() => assignMutation.mutate()}
|
||||||
'Перенести сервер? Привязка к аккаунту провайдера будет сброшена.',
|
/>
|
||||||
)
|
</>
|
||||||
) {
|
)}
|
||||||
return
|
|
||||||
}
|
|
||||||
assignMutation.mutate()
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Assign (перенос)
|
|
||||||
</Button>
|
|
||||||
</SheetFooter>
|
</SheetFooter>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</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> {
|
async function handoffOnUnauthorized(): Promise<void> {
|
||||||
clearToken()
|
clearToken()
|
||||||
const cfg = await ensureAuthConfig()
|
const cfg = await ensureAuthConfig()
|
||||||
@@ -296,10 +306,16 @@ export const api = {
|
|||||||
|
|
||||||
fetchSpaceGrants: (spaceId: string) =>
|
fetchSpaceGrants: (spaceId: string) =>
|
||||||
fetchApi<{
|
fetchApi<{
|
||||||
incoming: unknown[]
|
incoming: SpaceVpsGrant[]
|
||||||
outgoing: unknown[]
|
outgoing: SpaceVpsGrant[]
|
||||||
}>(`/api/spaces/${encodeURIComponent(spaceId)}/vps-grants`),
|
}>(`/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) =>
|
importBackupJson: (payload: unknown) =>
|
||||||
fetchApi('/api/backup/json', { method: 'POST', body: JSON.stringify(payload) }),
|
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 { ProjectEditSheet, projectFormDefaults } from '@/components/domain/project-edit-sheet'
|
||||||
import type { ProjectFormValues } from '@/lib/schemas'
|
import type { ProjectFormValues } from '@/lib/schemas'
|
||||||
import { Button } from '@cfdm/ui/components/button'
|
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 { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
|
import { copyText } from '@/lib/clipboard'
|
||||||
import { KpiStatGridSkeleton, TableSkeleton } from '@/components/skeletons'
|
import { KpiStatGridSkeleton, TableSkeleton } from '@/components/skeletons'
|
||||||
import {
|
import {
|
||||||
formatCurrency,
|
formatCurrency,
|
||||||
@@ -129,15 +136,31 @@ function ProjectDetailPage() {
|
|||||||
const columns: DataGridColumn<Vps>[] = [
|
const columns: DataGridColumn<Vps>[] = [
|
||||||
{
|
{
|
||||||
key: 'ip',
|
key: 'ip',
|
||||||
header: 'IP',
|
header: 'IP / DNS',
|
||||||
cell: (v) => (
|
cell: (v) => (
|
||||||
<Button
|
<div className="flex flex-col gap-0.5">
|
||||||
variant="link"
|
{v.ip ? (
|
||||||
className="h-auto p-0 font-medium"
|
<Button
|
||||||
render={<Link to="/vps/$vpsId" params={{ vpsId: v.id }} />}
|
type="button"
|
||||||
>
|
variant="link"
|
||||||
{v.ip || v.id}
|
className="h-auto justify-start p-0 font-medium"
|
||||||
</Button>
|
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() ? (
|
{project.notes?.trim() ? (
|
||||||
<Card>
|
<Frame spacing="sm" className="w-full">
|
||||||
<CardHeader>
|
<FrameHeader>
|
||||||
<CardTitle>Заметки</CardTitle>
|
<FrameTitle>Заметки</FrameTitle>
|
||||||
</CardHeader>
|
<FrameDescription>Комментарии к проекту</FrameDescription>
|
||||||
<CardContent>
|
</FrameHeader>
|
||||||
|
<FramePanel>
|
||||||
<p className="text-sm whitespace-pre-wrap">{project.notes}</p>
|
<p className="text-sm whitespace-pre-wrap">{project.notes}</p>
|
||||||
</CardContent>
|
</FramePanel>
|
||||||
</Card>
|
</Frame>
|
||||||
) : null}
|
) : null}
|
||||||
<ResourcePage
|
<ResourcePage
|
||||||
title="VPS проекта"
|
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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
@@ -44,6 +44,7 @@ export const Route = createFileRoute('/_auth/projects')({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function ProjectsPage() {
|
function ProjectsPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
const { data: snapshot, isLoading, isError, error, refetch } = useQuery(snapshotQueryOptions())
|
||||||
const settings = snapshot?.settings?.[0]
|
const settings = snapshot?.settings?.[0]
|
||||||
@@ -133,7 +134,7 @@ function ProjectsPage() {
|
|||||||
header: 'Проект',
|
header: 'Проект',
|
||||||
icon: FolderKanbanIcon,
|
icon: FolderKanbanIcon,
|
||||||
cell: (row) => (
|
cell: (row) => (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||||
<ProjectColorDot color={row.color} />
|
<ProjectColorDot color={row.color} />
|
||||||
<Button
|
<Button
|
||||||
variant="link"
|
variant="link"
|
||||||
@@ -184,18 +185,20 @@ function ProjectsPage() {
|
|||||||
sortable: false,
|
sortable: false,
|
||||||
className: 'w-24 text-right',
|
className: 'w-24 text-right',
|
||||||
cell: (row) => (
|
cell: (row) => (
|
||||||
<RowActions
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
onEdit={() => openEdit(row)}
|
<RowActions
|
||||||
onDelete={() => {
|
onEdit={() => openEdit(row)}
|
||||||
if (row.vpsTotal > 0) {
|
onDelete={() => {
|
||||||
toast.error(`Нельзя удалить: к проекту привязано ${row.vpsTotal} VPS`)
|
if (row.vpsTotal > 0) {
|
||||||
return
|
toast.error(`Нельзя удалить: к проекту привязано ${row.vpsTotal} VPS`)
|
||||||
}
|
return
|
||||||
delMut.mutate(row.id)
|
}
|
||||||
}}
|
delMut.mutate(row.id)
|
||||||
deleteTitle="Удалить проект?"
|
}}
|
||||||
deleteDescription={`«${row.name}» будет удалён.`}
|
deleteTitle="Удалить проект?"
|
||||||
/>
|
deleteDescription={`«${row.name}» будет удалён.`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -296,6 +299,12 @@ function ProjectsPage() {
|
|||||||
data={rows}
|
data={rows}
|
||||||
getRowId={(r) => r.id}
|
getRowId={(r) => r.id}
|
||||||
pinLastColumn
|
pinLastColumn
|
||||||
|
onRowClick={(row) =>
|
||||||
|
void navigate({
|
||||||
|
to: '/projects/$projectId',
|
||||||
|
params: { projectId: row.id },
|
||||||
|
})
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { ProjectColorDot } from '@/components/project-color-dot'
|
|||||||
import { VpsBulkToolbar } from '@/components/domain/vps-bulk-toolbar'
|
import { VpsBulkToolbar } from '@/components/domain/vps-bulk-toolbar'
|
||||||
import { VpsDomainsCell, UnmatchedDomainsBanner } from '@/components/integrations/vps-domains-cell'
|
import { VpsDomainsCell, UnmatchedDomainsBanner } from '@/components/integrations/vps-domains-cell'
|
||||||
import { VpsAccessSheet } from '@/components/vps-access-sheet'
|
import { VpsAccessSheet } from '@/components/vps-access-sheet'
|
||||||
|
import { copyText } from '@/lib/clipboard'
|
||||||
|
|
||||||
import type { Vps } from '@/types/entities'
|
import type { Vps } from '@/types/entities'
|
||||||
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
|
import { providerByIdMap, accountSelectLabel } from '@/lib/billmanager'
|
||||||
@@ -343,12 +344,30 @@ function VpsPage() {
|
|||||||
header: 'IP / DNS',
|
header: 'IP / DNS',
|
||||||
icon: GlobeIcon,
|
icon: GlobeIcon,
|
||||||
sortValue: (v) => v.ip || v.dns || '',
|
sortValue: (v) => v.ip || v.dns || '',
|
||||||
cell: (v) => dataGridCellStack(
|
cell: (v) =>
|
||||||
<Button variant="link" className="h-auto p-0 font-normal" render={<Link to="/vps/$vpsId" params={{ vpsId: v.id }} />}>
|
dataGridCellStack(
|
||||||
{v.ip || '—'}
|
v.ip ? (
|
||||||
</Button>,
|
<Button
|
||||||
v.dns || undefined,
|
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',
|
key: 'domains',
|
||||||
|
|||||||
Reference in New Issue
Block a user