Enhance frontend documentation and UI components; update frontend-shadcn.mdc to include UI patterns reference; improve navigation structure in AppSidebar by categorizing items; refactor DataTableCard to support empty states with EmptyState component; implement ConfirmDialog for delete actions in DnsRecordsTable and GroupsPage; add search functionality in CertificatesPage and GroupsPage for better user experience; update ServiceEditSheet to utilize tabs for organization.
Build, Test, and Push CFDM Docker Image / test (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / create-release (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / test (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / build-and-push (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / create-release (push) Has been cancelled
Build, Test, and Push CFDM Docker Image / update-wiki (push) Has been cancelled
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { ServerIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
domainKeys,
|
||||
@@ -20,6 +21,9 @@ import type {
|
||||
UpdateServiceConfigInput,
|
||||
} from '@/lib/schemas'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { ServiceEditSheet } from '@/components/service-edit-sheet'
|
||||
import { ServiceGroupCard } from '@/components/service-group-card'
|
||||
import { ServiceGroupEditSheet } from '@/components/service-group-edit-sheet'
|
||||
@@ -31,17 +35,16 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@cfdm/ui/components/card'
|
||||
import {
|
||||
Empty,
|
||||
EmptyContent,
|
||||
EmptyDescription,
|
||||
EmptyHeader,
|
||||
EmptyTitle,
|
||||
} from '@cfdm/ui/components/empty'
|
||||
import { ItemGroup } from '@cfdm/ui/components/item'
|
||||
import { Spinner } from '@cfdm/ui/components/spinner'
|
||||
import { Skeleton } from '@cfdm/ui/components/skeleton'
|
||||
|
||||
export const Route = createFileRoute('/_auth/services')({
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
domainId:
|
||||
search.domainId != null && search.domainId !== ''
|
||||
? Number(search.domainId)
|
||||
: undefined,
|
||||
}),
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
Promise.all([
|
||||
queryClient.ensureQueryData(serviceGroupsQueryOptions()),
|
||||
@@ -88,7 +91,13 @@ function setGroupEnabled(
|
||||
}
|
||||
}
|
||||
|
||||
function serviceMatchesDomain(service: ServiceView, domainId?: number) {
|
||||
if (!domainId) return true
|
||||
return service.domains?.some((d) => d.domain_id === domainId) ?? false
|
||||
}
|
||||
|
||||
function ServicesPage() {
|
||||
const { domainId } = Route.useSearch()
|
||||
const [createSheetOpen, setCreateSheetOpen] = useState(false)
|
||||
const [createGroupSheetOpen, setCreateGroupSheetOpen] = useState(false)
|
||||
const [editingGroup, setEditingGroup] = useState<ServiceGroupView | null>(null)
|
||||
@@ -98,9 +107,19 @@ function ServicesPage() {
|
||||
const [togglingServiceId, setTogglingServiceId] = useState<number | null>(null)
|
||||
const [togglingGroupId, setTogglingGroupId] = useState<number | null>(null)
|
||||
const queryClient = useQueryClient()
|
||||
const { data, isLoading } = useQuery(serviceGroupsQueryOptions())
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
refetch,
|
||||
} = useQuery(serviceGroupsQueryOptions())
|
||||
const { data: domains } = useQuery(domainsListQueryOptions())
|
||||
|
||||
const filteredDomain = domainId
|
||||
? domains?.find((d) => d.id === domainId)
|
||||
: undefined
|
||||
|
||||
function invalidateAll() {
|
||||
queryClient.invalidateQueries({ queryKey: serviceGroupKeys.all })
|
||||
queryClient.invalidateQueries({ queryKey: serviceKeys.all })
|
||||
@@ -286,15 +305,34 @@ function ServicesPage() {
|
||||
toggleGroupMutation.mutate({ id: groupId, enabled })
|
||||
}
|
||||
|
||||
const groups = data?.groups ?? []
|
||||
const ungrouped = data?.ungrouped ?? []
|
||||
const groups = useMemo(() => {
|
||||
const list = data?.groups ?? []
|
||||
if (!domainId) return list
|
||||
return list
|
||||
.map((group) => ({
|
||||
...group,
|
||||
services: group.services.filter((s) => serviceMatchesDomain(s, domainId)),
|
||||
}))
|
||||
.filter((group) => group.services.length > 0)
|
||||
}, [data?.groups, domainId])
|
||||
|
||||
const ungrouped = useMemo(() => {
|
||||
const list = data?.ungrouped ?? []
|
||||
if (!domainId) return list
|
||||
return list.filter((s) => serviceMatchesDomain(s, domainId))
|
||||
}, [data?.ungrouped, domainId])
|
||||
|
||||
const isEmpty = groups.length === 0 && ungrouped.length === 0
|
||||
|
||||
return (
|
||||
<div className="@container/main flex flex-col gap-4 py-4 md:gap-6 md:py-6">
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Сервисы"
|
||||
description="Домен группы (FQDN) и FQDN сервисов синхронизируются в Cloudflare при включении"
|
||||
description={
|
||||
filteredDomain
|
||||
? `Сервисы с привязками к домену ${filteredDomain.zone_name}`
|
||||
: 'Сервисы и группы сервисов — FQDN синхронизируются в Cloudflare при включении'
|
||||
}
|
||||
actions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" onClick={() => setCreateGroupSheetOpen(true)}>
|
||||
@@ -305,28 +343,37 @@ function ServicesPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Spinner className="size-8" />
|
||||
</div>
|
||||
) : isEmpty ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>Сервисы не найдены</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
Создайте группу и сервис — например VPN Panel или Home Assistant.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
<Button variant="outline" onClick={() => setCreateGroupSheetOpen(true)}>
|
||||
Добавить группу
|
||||
</Button>
|
||||
<Button onClick={() => setCreateSheetOpen(true)}>Добавить сервис</Button>
|
||||
</div>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
) : (
|
||||
<QueryState
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
onRetry={refetch}
|
||||
skeleton={
|
||||
<div className="flex flex-col gap-4">
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{isEmpty ? (
|
||||
<EmptyState
|
||||
icon={ServerIcon}
|
||||
title="Сервисы не найдены"
|
||||
description={
|
||||
filteredDomain
|
||||
? `Нет сервисов с привязками к ${filteredDomain.zone_name}`
|
||||
: 'Создайте группу и сервис — например VPN Panel или Home Assistant.'
|
||||
}
|
||||
action={
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
<Button variant="outline" onClick={() => setCreateGroupSheetOpen(true)}>
|
||||
Добавить группу
|
||||
</Button>
|
||||
<Button onClick={() => setCreateSheetOpen(true)}>Добавить сервис</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{groups.map((group) => (
|
||||
<ServiceGroupCard
|
||||
@@ -362,7 +409,8 @@ function ServicesPage() {
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</QueryState>
|
||||
|
||||
<ServiceEditSheet
|
||||
mode="edit"
|
||||
@@ -409,6 +457,6 @@ function ServicesPage() {
|
||||
}}
|
||||
onSave={(id, body) => updateGroupMutation.mutate({ id, body })}
|
||||
/>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user