Compare commits
4 Commits
47d31837e9
...
e229f0ae50
| Author | SHA1 | Date | |
|---|---|---|---|
| e229f0ae50 | |||
|
|
311a5422a9 | ||
|
|
132be581f5 | ||
|
|
c43642218e |
@@ -6,11 +6,26 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
import AdminLayout from './AdminLayout'
|
import AdminLayout from './AdminLayout'
|
||||||
import { useAuthStore } from '@/features/auth/stores/auth-store'
|
import { useAuthStore } from '@/features/auth/stores/auth-store'
|
||||||
import { getAlarms } from '@/features/admin/alerts/api/alerts-api'
|
import { getAlarms } from '@/features/admin/alerts/api/alerts-api'
|
||||||
|
import { getProjectFullConfig, listProjects } from '@/features/admin/projects/api/projects-api'
|
||||||
|
|
||||||
vi.mock('@/features/admin/alerts/api/alerts-api', () => ({
|
vi.mock('@/features/admin/alerts/api/alerts-api', () => ({
|
||||||
getAlarms: vi.fn(async () => []),
|
getAlarms: vi.fn(async () => []),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/features/admin/projects/api/projects-api', () => ({
|
||||||
|
listProjects: vi.fn(async () => []),
|
||||||
|
getProjectFullConfig: vi.fn(async () => ({ project: { id: 'project-1', name: 'Bloque Norte' }, assets: [] })),
|
||||||
|
}))
|
||||||
|
|
||||||
|
async function flushQueries() {
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function resetAuthStore() {
|
function resetAuthStore() {
|
||||||
useAuthStore.setState(useAuthStore.getInitialState(), true)
|
useAuthStore.setState(useAuthStore.getInitialState(), true)
|
||||||
useAuthStore.persist.clearStorage()
|
useAuthStore.persist.clearStorage()
|
||||||
@@ -42,6 +57,14 @@ describe('AdminLayout visual shell', () => {
|
|||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
resetAuthStore()
|
resetAuthStore()
|
||||||
seedAdminSession()
|
seedAdminSession()
|
||||||
|
vi.mocked(listProjects).mockResolvedValue([
|
||||||
|
{ id: 'project-1', name: 'Bloque Norte', client: 'Cliente', operator_name: 'Operador', contract_number: '001', is_active: true, created_at: '2026-06-22T00:00:00Z' },
|
||||||
|
{ id: 'project-2', name: 'Bloque Sur', client: 'Cliente', operator_name: 'Operador', contract_number: '002', is_active: true, created_at: '2026-06-22T00:00:00Z' },
|
||||||
|
])
|
||||||
|
vi.mocked(getProjectFullConfig).mockResolvedValue({
|
||||||
|
project: { id: 'project-1', name: 'Bloque Norte', client: 'Cliente', operator_name: 'Operador', contract_number: '001', is_active: true, created_at: '2026-06-22T00:00:00Z' },
|
||||||
|
assets: [],
|
||||||
|
})
|
||||||
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
container = document.createElement('div')
|
container = document.createElement('div')
|
||||||
document.body.appendChild(container)
|
document.body.appendChild(container)
|
||||||
@@ -238,6 +261,79 @@ describe('AdminLayout visual shell', () => {
|
|||||||
expect(topbar?.querySelector('[aria-label="Cerrar sesión"]')).not.toBeNull()
|
expect(topbar?.querySelector('[aria-label="Cerrar sesión"]')).not.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('renders real project wells under the Pozos sidebar group with active alarm badges', async () => {
|
||||||
|
vi.mocked(getAlarms).mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'alarm-1',
|
||||||
|
project_id: 'project-1',
|
||||||
|
asset_id: 'well-1',
|
||||||
|
variable_alias: 'presion',
|
||||||
|
alarm_type: 'ABOVE_MAX',
|
||||||
|
acknowledged: false,
|
||||||
|
timestamp: '2026-06-22T00:00:00Z',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
vi.mocked(getProjectFullConfig).mockResolvedValue({
|
||||||
|
project: { id: 'project-1', name: 'Bloque Norte', client: 'Cliente', operator_name: 'Operador', contract_number: '001', is_active: true, created_at: '2026-06-22T00:00:00Z' },
|
||||||
|
assets: [
|
||||||
|
{
|
||||||
|
asset: {
|
||||||
|
id: 'well-1',
|
||||||
|
project_id: 'project-1',
|
||||||
|
code: 'PZ-01',
|
||||||
|
name: 'Pozo Norte',
|
||||||
|
asset_type: 'POZO',
|
||||||
|
is_active: true,
|
||||||
|
metadata: {},
|
||||||
|
is_collector_enabled: true,
|
||||||
|
created_at: '2026-06-22T00:00:00Z',
|
||||||
|
updated_at: '2026-06-22T00:00:00Z',
|
||||||
|
},
|
||||||
|
devices: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
asset: {
|
||||||
|
id: 'tank-1',
|
||||||
|
project_id: 'project-1',
|
||||||
|
code: 'TK-01',
|
||||||
|
name: 'Tanque Norte',
|
||||||
|
asset_type: 'TANQUE',
|
||||||
|
is_active: true,
|
||||||
|
metadata: {},
|
||||||
|
is_collector_enabled: true,
|
||||||
|
created_at: '2026-06-22T00:00:00Z',
|
||||||
|
updated_at: '2026-06-22T00:00:00Z',
|
||||||
|
},
|
||||||
|
devices: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter initialEntries={['/app/admin/wells/well-1']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/app/admin/*" element={<AdminLayout />}>
|
||||||
|
<Route path="wells/:wellId" element={<div>Dashboard del pozo</div>} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
await flushQueries()
|
||||||
|
|
||||||
|
expect(getProjectFullConfig).toHaveBeenCalledWith('project-1')
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(container?.textContent).toContain('Pozo Norte')
|
||||||
|
})
|
||||||
|
expect(container?.textContent).toContain('Pozos')
|
||||||
|
expect(container?.textContent).not.toContain('Tanque Norte')
|
||||||
|
expect(container?.querySelector('a[href="/app/admin/wells/well-1"]')?.className).toContain('bg-[#24282e]')
|
||||||
|
expect(container?.textContent).toContain('1')
|
||||||
|
})
|
||||||
|
|
||||||
it('restores the persisted collapsed desktop preference on render', async () => {
|
it('restores the persisted collapsed desktop preference on render', async () => {
|
||||||
localStorage.setItem('omnioil-admin-sidebar-collapsed', 'true')
|
localStorage.setItem('omnioil-admin-sidebar-collapsed', 'true')
|
||||||
|
|
||||||
|
|||||||
@@ -18,12 +18,12 @@ import {
|
|||||||
Menu,
|
Menu,
|
||||||
X,
|
X,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
ChevronDown
|
ChevronDown,
|
||||||
|
Gauge,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { cn } from '@/lib/utils/cn'
|
import { cn } from '@/lib/utils/cn'
|
||||||
import { listProjects } from '@/features/admin/projects/api/projects-api'
|
import { getProjectFullConfig, listProjects } from '@/features/admin/projects/api/projects-api'
|
||||||
import { listAssets } from '@/features/admin/projects/api/wells-api'
|
|
||||||
|
|
||||||
const SIDEBAR_COLLAPSED_STORAGE_KEY = 'omnioil-admin-sidebar-collapsed'
|
const SIDEBAR_COLLAPSED_STORAGE_KEY = 'omnioil-admin-sidebar-collapsed'
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ export default function AdminLayout() {
|
|||||||
return localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY) === 'true'
|
return localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY) === 'true'
|
||||||
})
|
})
|
||||||
const [isProjectsNavOpen, setIsProjectsNavOpen] = useState(true)
|
const [isProjectsNavOpen, setIsProjectsNavOpen] = useState(true)
|
||||||
const [collapsedSidebarProjectIds, setCollapsedSidebarProjectIds] = useState<string[]>([])
|
const [isWellsNavOpen, setIsWellsNavOpen] = useState(true)
|
||||||
|
|
||||||
const canFetchAlarms = !!accessToken && (isPlatformGlobalRole(user?.role) || !!activeProjectId)
|
const canFetchAlarms = !!accessToken && (isPlatformGlobalRole(user?.role) || !!activeProjectId)
|
||||||
|
|
||||||
@@ -112,25 +112,19 @@ export default function AdminLayout() {
|
|||||||
enabled: !!accessToken && ['admin', 'superadmin'].includes(user?.role || ''),
|
enabled: !!accessToken && ['admin', 'superadmin'].includes(user?.role || ''),
|
||||||
staleTime: 60000,
|
staleTime: 60000,
|
||||||
})
|
})
|
||||||
const unacknowledgedCount = alarmsQuery.data?.filter((a) => !a.acknowledged).length ?? 0
|
|
||||||
const unacknowledgedAlarms = alarmsQuery.data?.filter((alarm) => !alarm.acknowledged) ?? []
|
|
||||||
const sidebarProjects = sidebarProjectsQuery.data ?? []
|
const sidebarProjects = sidebarProjectsQuery.data ?? []
|
||||||
const sidebarProjectWellsQuery = useQuery({
|
const sidebarProjectId = activeProjectId ?? (sidebarProjects.length === 1 ? sidebarProjects[0]?.id : null)
|
||||||
queryKey: ['admin-sidebar-project-wells', sidebarProjects.map((project) => project.id)],
|
const sidebarProjectConfigQuery = useQuery({
|
||||||
queryFn: async () => {
|
queryKey: ['admin-sidebar-project-config', sidebarProjectId],
|
||||||
const assetsByProject = await Promise.all(
|
queryFn: () => getProjectFullConfig(sidebarProjectId!),
|
||||||
sidebarProjects.map(async (project) => ({
|
enabled: !!accessToken && !!sidebarProjectId && ['admin', 'superadmin', 'auditor'].includes(user?.role || ''),
|
||||||
project,
|
|
||||||
wells: (await listAssets(project.id)).filter((asset) => asset.asset_type === 'POZO'),
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
|
|
||||||
return assetsByProject
|
|
||||||
},
|
|
||||||
enabled: sidebarProjects.length > 0 && !isDesktopSidebarCollapsed,
|
|
||||||
staleTime: 60000,
|
staleTime: 60000,
|
||||||
})
|
})
|
||||||
const sidebarProjectWells = sidebarProjectWellsQuery.data ?? []
|
const unacknowledgedCount = alarmsQuery.data?.filter((a) => !a.acknowledged).length ?? 0
|
||||||
|
const unacknowledgedAlarms = alarmsQuery.data?.filter((alarm) => !alarm.acknowledged) ?? []
|
||||||
|
const sidebarWells = sidebarProjectConfigQuery.data?.assets
|
||||||
|
.map(({ asset }) => asset)
|
||||||
|
.filter((asset) => asset.asset_type === 'POZO') ?? []
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout()
|
logout()
|
||||||
@@ -156,7 +150,8 @@ export default function AdminLayout() {
|
|||||||
const activeNavItem = filteredSections
|
const activeNavItem = filteredSections
|
||||||
.flatMap((section) => section.items)
|
.flatMap((section) => section.items)
|
||||||
.find((item) => isNavItemActive(item.to, location))
|
.find((item) => isNavItemActive(item.to, location))
|
||||||
const currentSectionLabel = activeNavItem?.label ?? 'Administración'
|
const isWellRouteActive = location.pathname.startsWith('/app/admin/wells/')
|
||||||
|
const currentSectionLabel = activeNavItem?.label ?? (isWellRouteActive ? 'Pozos' : 'Administración')
|
||||||
const userInitial = user?.email?.[0]?.toUpperCase() ?? 'U'
|
const userInitial = user?.email?.[0]?.toUpperCase() ?? 'U'
|
||||||
const alertsButtonLabel = unacknowledgedCount > 0
|
const alertsButtonLabel = unacknowledgedCount > 0
|
||||||
? `Ver ${unacknowledgedCount} alertas pendientes`
|
? `Ver ${unacknowledgedCount} alertas pendientes`
|
||||||
@@ -164,8 +159,8 @@ export default function AdminLayout() {
|
|||||||
const getProjectAlarmCount = (projectId: string) => {
|
const getProjectAlarmCount = (projectId: string) => {
|
||||||
return unacknowledgedAlarms.filter((alarm) => alarm.project_id === projectId).length
|
return unacknowledgedAlarms.filter((alarm) => alarm.project_id === projectId).length
|
||||||
}
|
}
|
||||||
const getAssetAlarmCount = (assetId: string) => {
|
const getWellAlarmCount = (wellId: string) => {
|
||||||
return unacknowledgedAlarms.filter((alarm) => alarm.asset_id === assetId).length
|
return unacknowledgedAlarms.filter((alarm) => alarm.asset_id === wellId).length
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -311,22 +306,16 @@ export default function AdminLayout() {
|
|||||||
{sidebarProjects.length > 0 && isProjectsNavOpen && !isDesktopSidebarCollapsed && (
|
{sidebarProjects.length > 0 && isProjectsNavOpen && !isDesktopSidebarCollapsed && (
|
||||||
<div className="ml-4 mt-1 space-y-0.5 border-l border-[#232831] pl-3">
|
<div className="ml-4 mt-1 space-y-0.5 border-l border-[#232831] pl-3">
|
||||||
{sidebarProjects.map((project) => {
|
{sidebarProjects.map((project) => {
|
||||||
const projectWells = sidebarProjectWells.find((entry) => entry.project.id === project.id)?.wells ?? []
|
|
||||||
const isProjectExpanded = !collapsedSidebarProjectIds.includes(project.id)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={project.id}>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{(() => {
|
|
||||||
const projectAlarmCount = getProjectAlarmCount(project.id)
|
const projectAlarmCount = getProjectAlarmCount(project.id)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div key={project.id}>
|
||||||
<NavLink
|
<NavLink
|
||||||
to={`/app/admin/projects/${project.id}`}
|
to={`/app/admin/projects/${project.id}`}
|
||||||
title={project.name}
|
title={project.name}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
cn(
|
||||||
'flex min-w-0 flex-1 items-center gap-2 rounded-md px-2.5 py-1.5 text-[13px] transition-colors',
|
'flex min-w-0 items-center gap-2 rounded-md px-2.5 py-1.5 text-[13px] transition-colors',
|
||||||
isActive
|
isActive
|
||||||
? 'bg-[#24282e] text-white'
|
? 'bg-[#24282e] text-white'
|
||||||
: 'text-[#9aa8ba] hover:bg-[#171b20] hover:text-white'
|
: 'text-[#9aa8ba] hover:bg-[#171b20] hover:text-white'
|
||||||
@@ -339,62 +328,13 @@ export default function AdminLayout() {
|
|||||||
project.is_active ? 'bg-success' : 'bg-muted-foreground'
|
project.is_active ? 'bg-success' : 'bg-muted-foreground'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<span className="truncate">{project.name}</span>
|
<span className="min-w-0 flex-1 truncate">{project.name}</span>
|
||||||
{projectAlarmCount > 0 && (
|
{projectAlarmCount > 0 && (
|
||||||
<Badge className="ml-auto shrink-0 rounded-full border-0 bg-destructive px-1.5 text-[10px] font-semibold text-destructive-foreground shadow-sm shadow-red-500/20">
|
<Badge className="shrink-0 rounded-full border-0 bg-destructive px-1.5 text-[10px] font-semibold text-destructive-foreground shadow-sm shadow-red-500/20">
|
||||||
{projectAlarmCount}
|
{projectAlarmCount}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
)
|
|
||||||
})()}
|
|
||||||
|
|
||||||
{projectWells.length > 0 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setCollapsedSidebarProjectIds((ids) =>
|
|
||||||
ids.includes(project.id)
|
|
||||||
? ids.filter((id) => id !== project.id)
|
|
||||||
: [...ids, project.id]
|
|
||||||
)}
|
|
||||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-[#8b98aa] transition-colors hover:bg-[#171b20] hover:text-white"
|
|
||||||
aria-label={isProjectExpanded ? `Ocultar pozos de ${project.name}` : `Mostrar pozos de ${project.name}`}
|
|
||||||
aria-expanded={isProjectExpanded}
|
|
||||||
>
|
|
||||||
<ChevronDown className={cn('h-3.5 w-3.5 transition-transform', !isProjectExpanded && '-rotate-90')} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{projectWells.length > 0 && isProjectExpanded && (
|
|
||||||
<div className="ml-3 space-y-0.5 border-l border-[#232831] pl-2.5">
|
|
||||||
{projectWells.map((well) => {
|
|
||||||
const wellAlarmCount = getAssetAlarmCount(well.id)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NavLink
|
|
||||||
key={well.id}
|
|
||||||
to={`/app/admin/projects/${project.id}`}
|
|
||||||
title={`${well.name} · ${project.name}`}
|
|
||||||
className="flex min-w-0 items-center gap-2 rounded-md px-2.5 py-1.5 text-[12px] text-[#9aa8ba] transition-colors hover:bg-[#24282e] hover:text-white"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'h-1.5 w-1.5 shrink-0 rounded-full',
|
|
||||||
well.is_active ? 'bg-[#00b7ff]' : 'bg-muted-foreground'
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className="min-w-0 flex-1 truncate font-medium">{well.name}</span>
|
|
||||||
{wellAlarmCount > 0 && (
|
|
||||||
<Badge className="shrink-0 rounded-full border-0 bg-destructive px-1.5 text-[10px] font-semibold text-destructive-foreground shadow-sm shadow-red-500/20">
|
|
||||||
{wellAlarmCount}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</NavLink>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -435,6 +375,64 @@ export default function AdminLayout() {
|
|||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{section.label === 'General' && sidebarWells.length > 0 && !isDesktopSidebarCollapsed && (
|
||||||
|
<div className="mt-2 space-y-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsWellsNavOpen((open) => !open)}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-3 rounded-md px-2.5 py-2 text-sm transition-colors',
|
||||||
|
isWellRouteActive
|
||||||
|
? 'bg-[#24282e] text-white font-semibold [&>svg]:text-primary'
|
||||||
|
: 'text-[#9aa8ba] hover:bg-[#171b20] hover:text-white [&>svg]:text-[#8793a3] hover:[&>svg]:text-primary/90'
|
||||||
|
)}
|
||||||
|
aria-expanded={isWellsNavOpen}
|
||||||
|
aria-controls="admin-sidebar-wells"
|
||||||
|
>
|
||||||
|
<Gauge className="h-4 w-4 shrink-0" />
|
||||||
|
<span className="min-w-0 flex-1 text-left">Pozos</span>
|
||||||
|
<ChevronDown className={cn('h-3.5 w-3.5 transition-transform', !isWellsNavOpen && '-rotate-90')} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isWellsNavOpen && (
|
||||||
|
<div id="admin-sidebar-wells" className="ml-4 mt-1 space-y-0.5 border-l border-[#232831] pl-3">
|
||||||
|
{sidebarWells.map((well) => {
|
||||||
|
const wellAlarmCount = getWellAlarmCount(well.id)
|
||||||
|
const wellLabel = well.name || well.code || well.id.slice(0, 8)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={well.id}
|
||||||
|
to={`/app/admin/wells/${well.id}`}
|
||||||
|
title={wellLabel}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
'flex min-w-0 items-center gap-2 rounded-md px-2.5 py-1.5 text-[13px] transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-[#24282e] text-white'
|
||||||
|
: 'text-[#9aa8ba] hover:bg-[#171b20] hover:text-white'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'h-1.5 w-1.5 shrink-0 rounded-full',
|
||||||
|
well.is_active ? 'bg-success' : 'bg-muted-foreground'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1 truncate">{wellLabel}</span>
|
||||||
|
{wellAlarmCount > 0 && (
|
||||||
|
<Badge className="shrink-0 rounded-full border-0 bg-destructive px-1.5 text-[10px] font-semibold text-destructive-foreground shadow-sm shadow-red-500/20">
|
||||||
|
{wellAlarmCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -1,13 +1,29 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { apiClient } from '@/lib/api/client'
|
import { apiClient } from '@/lib/api/client'
|
||||||
import { useAuthStore } from '@/features/auth/stores/auth-store'
|
import { useAuthStore } from '@/features/auth/stores/auth-store'
|
||||||
import { deleteProject } from './projects-api'
|
import { deleteProject, getProjectFullConfig } from './projects-api'
|
||||||
|
|
||||||
vi.mock('@/lib/api/client', () => ({
|
vi.mock('@/lib/api/client', () => ({
|
||||||
apiClient: vi.fn(),
|
apiClient: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
describe('projects-api', () => {
|
describe('projects-api', () => {
|
||||||
|
it('gets full Edge project config through the typed full endpoint', async () => {
|
||||||
|
useAuthStore.setState({ accessToken: 'token', token: 'token' } as any, true)
|
||||||
|
vi.mocked(apiClient).mockResolvedValue({
|
||||||
|
project: { id: 'project-1', name: 'Proyecto Uno' },
|
||||||
|
assets: [{
|
||||||
|
asset: { id: 'asset-1', asset_code: 'PZ-01', asset_name: 'Pozo Norte', asset_type: 'POZO' },
|
||||||
|
devices: [],
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
|
||||||
|
const config = await getProjectFullConfig('project-1')
|
||||||
|
|
||||||
|
expect(apiClient).toHaveBeenCalledWith('/edge/projects/project-1/full', { token: 'token' })
|
||||||
|
expect(config.assets[0].asset).toMatchObject({ code: 'PZ-01', name: 'Pozo Norte' })
|
||||||
|
})
|
||||||
|
|
||||||
it('deletes an Edge project through the project endpoint', async () => {
|
it('deletes an Edge project through the project endpoint', async () => {
|
||||||
useAuthStore.setState({ accessToken: 'token', token: 'token' } as any, true)
|
useAuthStore.setState({ accessToken: 'token', token: 'token' } as any, true)
|
||||||
vi.mocked(apiClient).mockResolvedValue(undefined)
|
vi.mocked(apiClient).mockResolvedValue(undefined)
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { apiClient } from '@/lib/api/client'
|
import { apiClient } from '@/lib/api/client'
|
||||||
import { useAuthStore } from '@/features/auth/stores/auth-store'
|
import { useAuthStore } from '@/features/auth/stores/auth-store'
|
||||||
|
import type { EdgeDevice } from './devices-api'
|
||||||
|
import type { EdgeVariable } from './variables-api'
|
||||||
|
import type { EdgeAsset } from './wells-api'
|
||||||
|
|
||||||
export interface EdgeProject {
|
export interface EdgeProject {
|
||||||
id: string
|
id: string
|
||||||
@@ -15,6 +18,36 @@ export interface EdgeProject {
|
|||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EdgeAssetFullApiResponse = Omit<EdgeAsset, 'code' | 'name'> & {
|
||||||
|
code?: string
|
||||||
|
name?: string
|
||||||
|
asset_code?: string
|
||||||
|
asset_name?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EdgeDeviceFullConfig {
|
||||||
|
device: EdgeDevice
|
||||||
|
variables: EdgeVariable[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EdgeAssetFullConfig {
|
||||||
|
asset: EdgeAsset
|
||||||
|
devices: EdgeDeviceFullConfig[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EdgeProjectFullConfig {
|
||||||
|
project: EdgeProject
|
||||||
|
assets: EdgeAssetFullConfig[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EdgeProjectFullConfigApiResponse {
|
||||||
|
project: EdgeProject
|
||||||
|
assets: Array<{
|
||||||
|
asset: EdgeAssetFullApiResponse
|
||||||
|
devices: EdgeDeviceFullConfig[]
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateProjectRequest {
|
export interface CreateProjectRequest {
|
||||||
name: string
|
name: string
|
||||||
client: string
|
client: string
|
||||||
@@ -28,6 +61,23 @@ export async function getProject(projectId: string) {
|
|||||||
return apiClient<EdgeProject>(`/edge/projects/${projectId}`, { token })
|
return apiClient<EdgeProject>(`/edge/projects/${projectId}`, { token })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getProjectFullConfig(projectId: string): Promise<EdgeProjectFullConfig> {
|
||||||
|
const token = useAuthStore.getState().token
|
||||||
|
const config = await apiClient<EdgeProjectFullConfigApiResponse>(`/edge/projects/${projectId}/full`, { token })
|
||||||
|
|
||||||
|
return {
|
||||||
|
project: config.project,
|
||||||
|
assets: config.assets.map(({ asset, devices }) => ({
|
||||||
|
asset: {
|
||||||
|
...asset,
|
||||||
|
code: asset.code ?? asset.asset_code ?? '',
|
||||||
|
name: asset.name ?? asset.asset_name ?? '',
|
||||||
|
},
|
||||||
|
devices,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function listProjects() {
|
export async function listProjects() {
|
||||||
const token = useAuthStore.getState().token
|
const token = useAuthStore.getState().token
|
||||||
return apiClient<EdgeProject[]>('/edge/projects', { token })
|
return apiClient<EdgeProject[]>('/edge/projects', { token })
|
||||||
|
|||||||
@@ -5,13 +5,16 @@ import { AgentHealthSummary } from './AgentHealthSummary'
|
|||||||
import { EdgeProjectHeader } from './EdgeProjectHeader'
|
import { EdgeProjectHeader } from './EdgeProjectHeader'
|
||||||
import { ProjectActionToolbar } from './ProjectActionToolbar'
|
import { ProjectActionToolbar } from './ProjectActionToolbar'
|
||||||
import { WellCard } from './WellCard'
|
import { WellCard } from './WellCard'
|
||||||
import type { EdgeProject } from '../api/projects-api'
|
import type { TelemetryRecord } from '../api/telemetry-api'
|
||||||
|
import type { EdgeAssetFullConfig, EdgeProject } from '../api/projects-api'
|
||||||
import type { EdgeAsset } from '../api/wells-api'
|
import type { EdgeAsset } from '../api/wells-api'
|
||||||
|
|
||||||
type ProjectDashboardLayoutProps = {
|
type ProjectDashboardLayoutProps = {
|
||||||
project: EdgeProject | null
|
project: EdgeProject | null
|
||||||
projectId: string | undefined
|
projectId: string | undefined
|
||||||
assets: EdgeAsset[]
|
assets: EdgeAsset[]
|
||||||
|
assetConfigs?: EdgeAssetFullConfig[]
|
||||||
|
latestTelemetryByAssetId?: Record<string, TelemetryRecord | undefined>
|
||||||
deploying: boolean
|
deploying: boolean
|
||||||
deleting: boolean
|
deleting: boolean
|
||||||
onDownloadInstaller: () => void
|
onDownloadInstaller: () => void
|
||||||
@@ -22,6 +25,8 @@ type ProjectDashboardLayoutProps = {
|
|||||||
onCreateWell: () => void
|
onCreateWell: () => void
|
||||||
onRequestDelete: () => void
|
onRequestDelete: () => void
|
||||||
onConfigureAsset: (asset: EdgeAsset) => void
|
onConfigureAsset: (asset: EdgeAsset) => void
|
||||||
|
onToggleAssetCollector: (asset: EdgeAsset) => void
|
||||||
|
togglingCollectorAssetId?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function ScadaSectionLabel({ eyebrow, title }: { eyebrow: string; title: string }) {
|
function ScadaSectionLabel({ eyebrow, title }: { eyebrow: string; title: string }) {
|
||||||
@@ -40,6 +45,8 @@ function ProjectDashboardLayout({
|
|||||||
project,
|
project,
|
||||||
projectId,
|
projectId,
|
||||||
assets,
|
assets,
|
||||||
|
assetConfigs = [],
|
||||||
|
latestTelemetryByAssetId = {},
|
||||||
deploying,
|
deploying,
|
||||||
deleting,
|
deleting,
|
||||||
onDownloadInstaller,
|
onDownloadInstaller,
|
||||||
@@ -50,6 +57,8 @@ function ProjectDashboardLayout({
|
|||||||
onCreateWell,
|
onCreateWell,
|
||||||
onRequestDelete,
|
onRequestDelete,
|
||||||
onConfigureAsset,
|
onConfigureAsset,
|
||||||
|
onToggleAssetCollector,
|
||||||
|
togglingCollectorAssetId = null,
|
||||||
}: ProjectDashboardLayoutProps) {
|
}: ProjectDashboardLayoutProps) {
|
||||||
return (
|
return (
|
||||||
<section className="flex min-h-0 flex-1 flex-col bg-[#090b0f]" aria-label="Panel operativo SCADA">
|
<section className="flex min-h-0 flex-1 flex-col bg-[#090b0f]" aria-label="Panel operativo SCADA">
|
||||||
@@ -101,7 +110,15 @@ function ProjectDashboardLayout({
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3 xl:grid-cols-2">
|
<div className="grid gap-3 xl:grid-cols-2">
|
||||||
{assets.map((asset) => (
|
{assets.map((asset) => (
|
||||||
<WellCard key={asset.id} asset={asset} onConfigure={onConfigureAsset} />
|
<WellCard
|
||||||
|
key={asset.id}
|
||||||
|
asset={asset}
|
||||||
|
config={assetConfigs.find((item) => item.asset.id === asset.id)}
|
||||||
|
latestTelemetry={latestTelemetryByAssetId[asset.id]}
|
||||||
|
onConfigure={onConfigureAsset}
|
||||||
|
onToggleCollector={onToggleAssetCollector}
|
||||||
|
isTogglingCollector={togglingCollectorAssetId === asset.id}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import { EdgeProjectHeader } from './EdgeProjectHeader'
|
|||||||
import { ProjectActionToolbar } from './ProjectActionToolbar'
|
import { ProjectActionToolbar } from './ProjectActionToolbar'
|
||||||
import { ProjectDashboardLayout } from './ProjectDashboardLayout'
|
import { ProjectDashboardLayout } from './ProjectDashboardLayout'
|
||||||
import { WellCard } from './WellCard'
|
import { WellCard } from './WellCard'
|
||||||
import type { EdgeProject } from '../api/projects-api'
|
import type { EdgeAssetFullConfig, EdgeProject } from '../api/projects-api'
|
||||||
|
import type { TelemetryRecord } from '../api/telemetry-api'
|
||||||
import type { EdgeAsset } from '../api/wells-api'
|
import type { EdgeAsset } from '../api/wells-api'
|
||||||
|
|
||||||
vi.mock('./AgentLogViewer', () => ({
|
vi.mock('./AgentLogViewer', () => ({
|
||||||
@@ -61,6 +62,77 @@ const baseAsset: EdgeAsset = {
|
|||||||
updated_at: '2026-06-01T00:00:00Z',
|
updated_at: '2026-06-01T00:00:00Z',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const baseAssetConfig: EdgeAssetFullConfig = {
|
||||||
|
asset: baseAsset,
|
||||||
|
devices: [{
|
||||||
|
device: {
|
||||||
|
id: 'device-1',
|
||||||
|
asset_id: baseAsset.id,
|
||||||
|
name: 'PLC pozo uno',
|
||||||
|
protocol: 'MODBUS_TCP',
|
||||||
|
host: '192.168.10.20',
|
||||||
|
port: 502,
|
||||||
|
protocol_config: {},
|
||||||
|
is_enabled: true,
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
updated_at: '2026-06-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
variables: [
|
||||||
|
{
|
||||||
|
id: 'var-1',
|
||||||
|
device_id: 'device-1',
|
||||||
|
address: '40001',
|
||||||
|
data_type: 'float32',
|
||||||
|
alias: 'PRESION_CABEZA',
|
||||||
|
unit: 'psi',
|
||||||
|
polling_interval_ms: 1000,
|
||||||
|
alarm_enabled: true,
|
||||||
|
byte_order: 'ABCD',
|
||||||
|
metadata: {},
|
||||||
|
is_enabled: true,
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
updated_at: '2026-06-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'var-2',
|
||||||
|
device_id: 'device-1',
|
||||||
|
address: '40003',
|
||||||
|
data_type: 'float32',
|
||||||
|
alias: 'TEMPERATURA_MOTOR',
|
||||||
|
unit: '°C',
|
||||||
|
polling_interval_ms: 1000,
|
||||||
|
alarm_enabled: false,
|
||||||
|
byte_order: 'ABCD',
|
||||||
|
metadata: {},
|
||||||
|
is_enabled: true,
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
updated_at: '2026-06-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'var-3',
|
||||||
|
device_id: 'device-1',
|
||||||
|
address: '40005',
|
||||||
|
data_type: 'float32',
|
||||||
|
alias: 'CAUDAL',
|
||||||
|
unit: 'bpd',
|
||||||
|
polling_interval_ms: 1000,
|
||||||
|
alarm_enabled: false,
|
||||||
|
byte_order: 'ABCD',
|
||||||
|
metadata: {},
|
||||||
|
is_enabled: false,
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
updated_at: '2026-06-01T00:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
const recentTelemetry: TelemetryRecord = {
|
||||||
|
asset_id: baseAsset.id,
|
||||||
|
time: '2026-06-21T12:00:10Z',
|
||||||
|
data: { variables: { PRESION_CABEZA: { value: 540 } } },
|
||||||
|
}
|
||||||
|
|
||||||
function createLayoutProps(overrides: Partial<React.ComponentProps<typeof ProjectDashboardLayout>> = {}) {
|
function createLayoutProps(overrides: Partial<React.ComponentProps<typeof ProjectDashboardLayout>> = {}) {
|
||||||
return {
|
return {
|
||||||
project,
|
project,
|
||||||
@@ -76,6 +148,7 @@ function createLayoutProps(overrides: Partial<React.ComponentProps<typeof Projec
|
|||||||
onCreateWell: vi.fn(),
|
onCreateWell: vi.fn(),
|
||||||
onRequestDelete: vi.fn(),
|
onRequestDelete: vi.fn(),
|
||||||
onConfigureAsset: vi.fn(),
|
onConfigureAsset: vi.fn(),
|
||||||
|
onToggleAssetCollector: vi.fn(),
|
||||||
...overrides,
|
...overrides,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -303,15 +376,22 @@ describe('project detail extracted components', () => {
|
|||||||
external_api_url: 'https://collector.example.test/api/wells/WELL-01',
|
external_api_url: 'https://collector.example.test/api/wells/WELL-01',
|
||||||
}
|
}
|
||||||
|
|
||||||
const container = track(render(<WellCard asset={apiAsset} onConfigure={onConfigure} />))
|
const container = track(render(
|
||||||
|
<WellCard
|
||||||
|
asset={apiAsset}
|
||||||
|
config={{ ...baseAssetConfig, asset: apiAsset }}
|
||||||
|
onConfigure={onConfigure}
|
||||||
|
onToggleCollector={vi.fn()}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
expect(container.textContent).toContain('POZO · WELL-01')
|
expect(container.textContent).toContain('POZO · MODBUS_TCP')
|
||||||
expect(container.textContent).toContain('Pozo uno')
|
expect(container.textContent).toContain('Pozo uno')
|
||||||
expect(container.textContent).toContain('API configurada')
|
expect(container.textContent).toContain('Configurado')
|
||||||
expect(container.textContent).toContain('Endpoint')
|
expect(container.textContent).toContain('Endpoint')
|
||||||
expect(container.textContent).toContain('https://collector.example.test/api/wells/WELL-01')
|
expect(container.textContent).toContain('https://collector.example.test/api/wells/WELL-01')
|
||||||
expect(container.textContent).toContain('Variables')
|
expect(container.textContent).toContain('Variables')
|
||||||
expect(container.textContent).toContain('Sin conteo disponible')
|
expect(container.textContent).toContain('2/3 activas')
|
||||||
expect(container.textContent).not.toContain('Conectado')
|
expect(container.textContent).not.toContain('Conectado')
|
||||||
|
|
||||||
const configureButton = Array.from(container.querySelectorAll('button')).find((button) =>
|
const configureButton = Array.from(container.querySelectorAll('button')).find((button) =>
|
||||||
@@ -319,17 +399,109 @@ describe('project detail extracted components', () => {
|
|||||||
)
|
)
|
||||||
configureButton?.click()
|
configureButton?.click()
|
||||||
|
|
||||||
const connectButton = Array.from(container.querySelectorAll('button')).find((button) =>
|
const collectorButton = Array.from(container.querySelectorAll('button')).find((button) =>
|
||||||
button.textContent?.includes('Conectar')
|
button.textContent?.includes('Deshabilitar colector')
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(configureButton).toBeTruthy()
|
expect(configureButton).toBeTruthy()
|
||||||
expect(connectButton).toBeTruthy()
|
expect(collectorButton).toBeTruthy()
|
||||||
expect(connectButton?.disabled).toBe(true)
|
expect(collectorButton?.disabled).toBe(false)
|
||||||
expect(connectButton?.getAttribute('aria-label')).toBe('Conectar no disponible: no hay API de conexión en esta vista')
|
expect(collectorButton?.getAttribute('aria-label')).toContain('Deshabilitar colector')
|
||||||
expect(onConfigure).toHaveBeenCalledWith(apiAsset)
|
expect(onConfigure).toHaveBeenCalledWith(apiAsset)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('enables a configured disabled collector and forwards the real asset action', () => {
|
||||||
|
const onToggleCollector = vi.fn()
|
||||||
|
const disabledCollectorAsset = {
|
||||||
|
...baseAsset,
|
||||||
|
is_collector_enabled: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = track(render(
|
||||||
|
<WellCard
|
||||||
|
asset={disabledCollectorAsset}
|
||||||
|
config={{ ...baseAssetConfig, asset: disabledCollectorAsset }}
|
||||||
|
onConfigure={vi.fn()}
|
||||||
|
onToggleCollector={onToggleCollector}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
const collectorButton = Array.from(container.querySelectorAll('button')).find((button) =>
|
||||||
|
button.textContent?.includes('Habilitar colector')
|
||||||
|
) as HTMLButtonElement
|
||||||
|
|
||||||
|
expect(collectorButton).toBeTruthy()
|
||||||
|
expect(collectorButton.disabled).toBe(false)
|
||||||
|
act(() => collectorButton.click())
|
||||||
|
expect(onToggleCollector).toHaveBeenCalledWith(disabledCollectorAsset)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('blocks collector enablement when only inactive variables are configured', () => {
|
||||||
|
const onToggleCollector = vi.fn()
|
||||||
|
const disabledCollectorAsset = {
|
||||||
|
...baseAsset,
|
||||||
|
is_collector_enabled: false,
|
||||||
|
}
|
||||||
|
const inactiveVariableConfig: EdgeAssetFullConfig = {
|
||||||
|
...baseAssetConfig,
|
||||||
|
asset: disabledCollectorAsset,
|
||||||
|
devices: baseAssetConfig.devices.map((item) => ({
|
||||||
|
...item,
|
||||||
|
variables: item.variables.map((variable) => ({ ...variable, is_enabled: false })),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = track(render(
|
||||||
|
<WellCard
|
||||||
|
asset={disabledCollectorAsset}
|
||||||
|
config={inactiveVariableConfig}
|
||||||
|
onConfigure={vi.fn()}
|
||||||
|
onToggleCollector={onToggleCollector}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
const collectorButton = Array.from(container.querySelectorAll('button')).find((button) =>
|
||||||
|
button.textContent?.includes('Habilitar colector')
|
||||||
|
) as HTMLButtonElement
|
||||||
|
|
||||||
|
expect(container.textContent).toContain('0/3 activas')
|
||||||
|
expect(collectorButton.disabled).toBe(true)
|
||||||
|
act(() => collectorButton.click())
|
||||||
|
expect(onToggleCollector).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders device endpoint and active variable count from full config', () => {
|
||||||
|
const container = track(render(<WellCard asset={baseAsset} config={baseAssetConfig} onConfigure={vi.fn()} />))
|
||||||
|
|
||||||
|
expect(container.textContent).toContain('MODBUS_TCP 192.168.10.20:502')
|
||||||
|
expect(container.textContent).toContain('2/3 activas')
|
||||||
|
expect(container.textContent).toContain('Configurado')
|
||||||
|
expect(container.textContent).not.toContain('Sin endpoint configurado')
|
||||||
|
expect(container.textContent).not.toContain('Sin conteo disponible')
|
||||||
|
expect(container.textContent).not.toContain('Conectado')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders connected only with recent telemetry from the asset', () => {
|
||||||
|
const container = track(render(
|
||||||
|
<WellCard asset={baseAsset} config={baseAssetConfig} latestTelemetry={recentTelemetry} onConfigure={vi.fn()} />
|
||||||
|
))
|
||||||
|
|
||||||
|
expect(container.textContent).toContain('Conectado')
|
||||||
|
expect(container.textContent).toContain('2/3 activas')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders stale telemetry as offline instead of connected', () => {
|
||||||
|
const container = track(render(
|
||||||
|
<WellCard
|
||||||
|
asset={baseAsset}
|
||||||
|
config={baseAssetConfig}
|
||||||
|
latestTelemetry={{ ...recentTelemetry, time: '2026-06-21T11:50:00Z' }}
|
||||||
|
onConfigure={vi.fn()}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
|
||||||
|
expect(container.textContent).toContain('Sin telemetría')
|
||||||
|
expect(container.textContent).not.toContain('Conectado')
|
||||||
|
})
|
||||||
|
|
||||||
it('renders disabled collector and missing endpoint without inventing connection health', () => {
|
it('renders disabled collector and missing endpoint without inventing connection health', () => {
|
||||||
const disabledCollectorAsset = {
|
const disabledCollectorAsset = {
|
||||||
...baseAsset,
|
...baseAsset,
|
||||||
@@ -338,12 +510,16 @@ describe('project detail extracted components', () => {
|
|||||||
is_collector_enabled: false,
|
is_collector_enabled: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
const container = track(render(<WellCard asset={disabledCollectorAsset} onConfigure={vi.fn()} />))
|
const container = track(render(<WellCard asset={disabledCollectorAsset} onConfigure={vi.fn()} onToggleCollector={vi.fn()} />))
|
||||||
|
|
||||||
expect(container.textContent).toContain('Colector deshabilitado')
|
expect(container.textContent).toContain('Colector deshabilitado')
|
||||||
expect(container.textContent).toContain('Sin endpoint configurado')
|
expect(container.textContent).toContain('Sin endpoint configurado')
|
||||||
expect(container.textContent).toContain('Sin conteo disponible')
|
expect(container.textContent).toContain('Sin conteo disponible')
|
||||||
expect(container.textContent).not.toContain('Conectado')
|
expect(container.textContent).not.toContain('Conectado')
|
||||||
|
const collectorButton = Array.from(container.querySelectorAll('button')).find((button) =>
|
||||||
|
button.textContent?.includes('Habilitar colector')
|
||||||
|
) as HTMLButtonElement
|
||||||
|
expect(collectorButton.disabled).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders sparse well metadata with stable fallback text', () => {
|
it('renders sparse well metadata with stable fallback text', () => {
|
||||||
@@ -363,7 +539,7 @@ describe('project detail extracted components', () => {
|
|||||||
const container = track(render(<WellCard asset={sparseAsset} onConfigure={vi.fn()} />))
|
const container = track(render(<WellCard asset={sparseAsset} onConfigure={vi.fn()} />))
|
||||||
|
|
||||||
expect(container.textContent).toContain('Pozo asset-sp')
|
expect(container.textContent).toContain('Pozo asset-sp')
|
||||||
expect(container.textContent).toContain('OTRO · Sin fuente configurada')
|
expect(container.textContent).toContain('OTRO')
|
||||||
expect(container.textContent).toContain('Colector deshabilitado')
|
expect(container.textContent).toContain('Colector deshabilitado')
|
||||||
expect(container.textContent).toContain('Sin endpoint configurado')
|
expect(container.textContent).toContain('Sin endpoint configurado')
|
||||||
expect(container.textContent).not.toContain('Sin metadatos adicionales')
|
expect(container.textContent).not.toContain('Sin metadatos adicionales')
|
||||||
@@ -413,6 +589,7 @@ describe('project detail extracted components', () => {
|
|||||||
baseAsset,
|
baseAsset,
|
||||||
{ ...baseAsset, id: 'asset-2', code: 'WELL-02', name: 'Pozo dos' },
|
{ ...baseAsset, id: 'asset-2', code: 'WELL-02', name: 'Pozo dos' },
|
||||||
],
|
],
|
||||||
|
assetConfigs: [baseAssetConfig],
|
||||||
})
|
})
|
||||||
|
|
||||||
const container = track(render(<ProjectDashboardLayout {...props} />))
|
const container = track(render(<ProjectDashboardLayout {...props} />))
|
||||||
@@ -471,6 +648,7 @@ describe('project detail extracted components', () => {
|
|||||||
clickButton('Registrar pozo')
|
clickButton('Registrar pozo')
|
||||||
clickButton('Eliminar proyecto')
|
clickButton('Eliminar proyecto')
|
||||||
clickButton('Configurar variables')
|
clickButton('Configurar variables')
|
||||||
|
clickButton('Deshabilitar colector')
|
||||||
|
|
||||||
expect(props.onDownloadInstaller).toHaveBeenCalledTimes(1)
|
expect(props.onDownloadInstaller).toHaveBeenCalledTimes(1)
|
||||||
expect(props.onGetLinuxInstall).toHaveBeenCalledTimes(1)
|
expect(props.onGetLinuxInstall).toHaveBeenCalledTimes(1)
|
||||||
@@ -480,5 +658,6 @@ describe('project detail extracted components', () => {
|
|||||||
expect(props.onCreateWell).toHaveBeenCalledTimes(1)
|
expect(props.onCreateWell).toHaveBeenCalledTimes(1)
|
||||||
expect(props.onRequestDelete).toHaveBeenCalledTimes(1)
|
expect(props.onRequestDelete).toHaveBeenCalledTimes(1)
|
||||||
expect(props.onConfigureAsset).toHaveBeenCalledWith(baseAsset)
|
expect(props.onConfigureAsset).toHaveBeenCalledWith(baseAsset)
|
||||||
|
expect(props.onToggleAssetCollector).toHaveBeenCalledWith(baseAsset)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,35 +1,75 @@
|
|||||||
import { Cable, Settings, MapPin } from 'lucide-react'
|
import { Cable, Loader2, Settings, MapPin } from 'lucide-react'
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import type { TelemetryRecord } from '../api/telemetry-api'
|
||||||
|
import type { EdgeAssetFullConfig } from '../api/projects-api'
|
||||||
import type { EdgeAsset } from '../api/wells-api'
|
import type { EdgeAsset } from '../api/wells-api'
|
||||||
|
|
||||||
type WellCardProps = {
|
type WellCardProps = {
|
||||||
asset: EdgeAsset
|
asset: EdgeAsset
|
||||||
|
config?: EdgeAssetFullConfig
|
||||||
|
latestTelemetry?: TelemetryRecord
|
||||||
onConfigure: (asset: EdgeAsset) => void
|
onConfigure: (asset: EdgeAsset) => void
|
||||||
|
onToggleCollector?: (asset: EdgeAsset) => void
|
||||||
|
isTogglingCollector?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWellDisplay(asset: EdgeAsset) {
|
type WellStatus = {
|
||||||
|
label: string
|
||||||
|
className: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusClasses = {
|
||||||
|
connected: 'border-[#27d796]/35 bg-[#27d796]/15 text-[#27d796]',
|
||||||
|
configured: 'border-[#38bdf8]/35 bg-[#38bdf8]/15 text-[#7dd3fc]',
|
||||||
|
incomplete: 'border-[#f59e0b]/35 bg-[#f59e0b]/15 text-[#fbbf24]',
|
||||||
|
offline: 'border-[#ff4655]/35 bg-[#ff4655]/15 text-[#ff4655]',
|
||||||
|
disabled: 'border-[#9aa8ba]/30 bg-[#9aa8ba]/10 text-[#9aa8ba]',
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTelemetryAgeSeconds(latestTelemetry?: TelemetryRecord, now = Date.now()) {
|
||||||
|
if (!latestTelemetry?.time) return null
|
||||||
|
const timestamp = new Date(latestTelemetry.time).getTime()
|
||||||
|
if (Number.isNaN(timestamp)) return null
|
||||||
|
return (now - timestamp) / 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWellDisplay(asset: EdgeAsset, config?: EdgeAssetFullConfig, latestTelemetry?: TelemetryRecord, now = Date.now()) {
|
||||||
const title = asset.name?.trim() || asset.code?.trim() || `Pozo ${asset.id.slice(0, 8)}`
|
const title = asset.name?.trim() || asset.code?.trim() || `Pozo ${asset.id.slice(0, 8)}`
|
||||||
const source = asset.code?.trim() || (asset.is_collector_enabled ? 'Colector habilitado' : 'Sin fuente configurada')
|
const firstDevice = config?.devices.find(({ device }) => device.is_enabled) ?? config?.devices[0]
|
||||||
const endpoint = asset.external_api_url?.trim() || 'Sin endpoint configurado'
|
const endpoint = asset.external_api_url?.trim()
|
||||||
const statusLabel = asset.external_api_url?.trim()
|
|| (firstDevice ? `${firstDevice.device.protocol} ${firstDevice.device.host}:${firstDevice.device.port}` : '')
|
||||||
? 'API configurada'
|
const totalVariables = config?.devices.reduce((count, item) => count + item.variables.length, 0)
|
||||||
: asset.is_collector_enabled
|
const activeVariables = config?.devices.reduce(
|
||||||
? 'Colector habilitado'
|
(count, item) => count + item.variables.filter((variable) => variable.is_enabled && item.device.is_enabled).length,
|
||||||
: asset.is_active
|
0
|
||||||
? 'Pozo activo'
|
)
|
||||||
: 'Pozo inactivo'
|
const hasEndpoint = endpoint.length > 0
|
||||||
|
const hasActiveVariables = typeof activeVariables === 'number' && activeVariables > 0
|
||||||
|
const telemetryAgeSeconds = getTelemetryAgeSeconds(latestTelemetry, now)
|
||||||
|
const status: WellStatus = !asset.is_active || !asset.is_collector_enabled
|
||||||
|
? { label: asset.is_active ? 'Colector deshabilitado' : 'Inactivo', className: statusClasses.disabled }
|
||||||
|
: telemetryAgeSeconds !== null && telemetryAgeSeconds < 60
|
||||||
|
? { label: 'Conectado', className: statusClasses.connected }
|
||||||
|
: telemetryAgeSeconds !== null && telemetryAgeSeconds >= 300
|
||||||
|
? { label: 'Sin telemetría', className: statusClasses.offline }
|
||||||
|
: hasEndpoint && hasActiveVariables
|
||||||
|
? { label: 'Configurado', className: statusClasses.configured }
|
||||||
|
: { label: 'Configuración incompleta', className: statusClasses.incomplete }
|
||||||
|
const protocol = firstDevice?.device.protocol
|
||||||
|
const subtitle = [asset.asset_type || 'Activo', protocol].filter(Boolean).join(' · ')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
subtitle: `${asset.asset_type || 'Activo'} · ${source}`,
|
subtitle,
|
||||||
endpoint,
|
endpoint: endpoint || 'Sin endpoint configurado',
|
||||||
statusLabel,
|
status,
|
||||||
activeLabel: asset.is_active ? 'Pozo activo' : 'Pozo inactivo',
|
activeLabel: asset.is_active ? 'Pozo activo' : 'Pozo inactivo',
|
||||||
collectorLabel: asset.is_collector_enabled ? 'Colector habilitado' : 'Colector deshabilitado',
|
collectorLabel: asset.is_collector_enabled ? 'Colector habilitado' : 'Colector deshabilitado',
|
||||||
variablesLabel: 'Sin conteo disponible',
|
variablesLabel: typeof totalVariables === 'number' ? `${activeVariables}/${totalVariables} activas` : 'Sin conteo disponible',
|
||||||
|
canEnableCollector: asset.is_active && hasEndpoint && hasActiveVariables,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,12 +82,17 @@ function DetailRow({ label, value }: { label: string; value: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function WellCard({ asset, onConfigure }: WellCardProps) {
|
function WellCard({ asset, config, latestTelemetry, onConfigure, onToggleCollector, isTogglingCollector = false }: WellCardProps) {
|
||||||
const display = getWellDisplay(asset)
|
const display = getWellDisplay(asset, config, latestTelemetry)
|
||||||
const metadataEntries = Object.entries(asset.metadata ?? {}).filter(([, value]) => value !== null && value !== undefined && value !== '')
|
const metadataEntries = Object.entries(asset.metadata ?? {}).filter(([, value]) => value !== null && value !== undefined && value !== '')
|
||||||
|
const canToggleCollector = Boolean(onToggleCollector) && (asset.is_collector_enabled || display.canEnableCollector)
|
||||||
|
const collectorActionLabel = asset.is_collector_enabled ? 'Deshabilitar colector' : 'Habilitar colector'
|
||||||
|
const collectorActionTitle = canToggleCollector
|
||||||
|
? `${collectorActionLabel} y desplegar configuración al agente edge`
|
||||||
|
: 'Configurá un endpoint y variables antes de habilitar el colector'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="overflow-hidden border-[#2a3038] bg-[#15191e] shadow-xl shadow-black/20 transition-colors hover:border-[#f97316]/45">
|
<Card className="overflow-hidden border-[#232a33] bg-[#11161c] shadow-xl shadow-black/25 transition-colors hover:border-[#f97316]/45">
|
||||||
<CardContent className="flex h-full flex-col gap-4 p-4">
|
<CardContent className="flex h-full flex-col gap-4 p-4">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex min-w-0 gap-3">
|
<div className="flex min-w-0 gap-3">
|
||||||
@@ -59,8 +104,8 @@ function WellCard({ asset, onConfigure }: WellCardProps) {
|
|||||||
<p className="mt-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-[#9aa8ba]">{display.subtitle}</p>
|
<p className="mt-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-[#9aa8ba]">{display.subtitle}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant="outline" className="shrink-0 border-[#f97316]/30 bg-[#f97316]/10 text-[#fed7aa]">
|
<Badge variant="outline" className={`shrink-0 ${display.status.className}`}>
|
||||||
{display.statusLabel}
|
{display.status.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -68,8 +113,8 @@ function WellCard({ asset, onConfigure }: WellCardProps) {
|
|||||||
<dl>
|
<dl>
|
||||||
<DetailRow label="Endpoint" value={display.endpoint} />
|
<DetailRow label="Endpoint" value={display.endpoint} />
|
||||||
<DetailRow label="Variables" value={display.variablesLabel} />
|
<DetailRow label="Variables" value={display.variablesLabel} />
|
||||||
<DetailRow label="Estado" value={display.activeLabel} />
|
|
||||||
<DetailRow label="Colector" value={display.collectorLabel} />
|
<DetailRow label="Colector" value={display.collectorLabel} />
|
||||||
|
<DetailRow label="Estado" value={display.activeLabel} />
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -94,13 +139,14 @@ function WellCard({ asset, onConfigure }: WellCardProps) {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
disabled
|
disabled={!canToggleCollector || isTogglingCollector}
|
||||||
title="No hay API de conexión en esta vista"
|
title={collectorActionTitle}
|
||||||
aria-label="Conectar no disponible: no hay API de conexión en esta vista"
|
aria-label={`${collectorActionLabel}: ${collectorActionTitle}`}
|
||||||
className="flex-1 border-[#2a3038] bg-transparent text-xs text-[#7d8a99]"
|
onClick={() => onToggleCollector?.(asset)}
|
||||||
|
className="flex-1 border-[#2a3038] bg-transparent text-xs text-[#d7e0ea] hover:border-[#38bdf8]/45 hover:bg-[#38bdf8]/10 disabled:text-[#7d8a99]"
|
||||||
>
|
>
|
||||||
<Cable className="h-3.5 w-3.5" />
|
{isTogglingCollector ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Cable className="h-3.5 w-3.5" />}
|
||||||
Conectar
|
{isTogglingCollector ? 'Aplicando cambio' : collectorActionLabel}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import { act } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { MemoryRouter, Route, Routes } from 'react-router-dom'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import ProjectDetailsPage from './ProjectDetailsPage'
|
||||||
|
import { useAuthStore } from '@/features/auth/stores/auth-store'
|
||||||
|
import { deploySystem, getProject, getProjectFullConfig, listDeployments } from '../api/projects-api'
|
||||||
|
import { getAssetTelemetry } from '../api/telemetry-api'
|
||||||
|
import { updateAsset } from '../api/wells-api'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
const layoutPropsSpy = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('../components/ProjectDashboardLayout', () => ({
|
||||||
|
ProjectDashboardLayout: (props: any) => {
|
||||||
|
layoutPropsSpy(props)
|
||||||
|
const asset = props.assets?.[0] ?? {
|
||||||
|
id: 'asset-1',
|
||||||
|
project_id: props.projectId,
|
||||||
|
code: 'WELL-01',
|
||||||
|
name: 'Pozo uno',
|
||||||
|
asset_type: 'POZO',
|
||||||
|
is_active: true,
|
||||||
|
metadata: { field: 'Norte' },
|
||||||
|
external_api_url: 'http://127.0.0.1:5020',
|
||||||
|
is_collector_enabled: false,
|
||||||
|
created_at: '2026-06-20T00:00:00Z',
|
||||||
|
updated_at: '2026-06-20T00:00:00Z',
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<section aria-label="Detalle de proyecto mock">
|
||||||
|
Proyecto {props.projectId}
|
||||||
|
<span>Activos mock: {props.assets?.length ?? 0}</span>
|
||||||
|
<span>Configs mock: {props.assetConfigs?.length ?? 0}</span>
|
||||||
|
<span>Telemetry mock: {props.latestTelemetryByAssetId?.[asset.id]?.time ?? 'none'}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => props.onToggleAssetCollector(asset)}
|
||||||
|
>
|
||||||
|
Habilitar colector mock
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../components/DeviceConfigModal', () => ({
|
||||||
|
default: () => null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../components/DeleteProjectConfirmDialog', () => ({
|
||||||
|
DeleteProjectConfirmDialog: () => null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../api/projects-api', () => ({
|
||||||
|
getProject: vi.fn(),
|
||||||
|
getProjectFullConfig: vi.fn(),
|
||||||
|
deploySystem: vi.fn(),
|
||||||
|
listDeployments: vi.fn(),
|
||||||
|
rebuildInstaller: vi.fn(),
|
||||||
|
getLinuxInstallScript: vi.fn(),
|
||||||
|
getLinuxInstallToken: vi.fn(),
|
||||||
|
deleteProject: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../api/wells-api', () => ({
|
||||||
|
createAsset: vi.fn(),
|
||||||
|
updateAsset: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../api/telemetry-api', () => ({
|
||||||
|
getAssetTelemetry: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/features/admin/telemetry/api/telemetry-ws-api', () => ({
|
||||||
|
createTelemetryWsTicket: vi.fn(() => new Promise(() => {})),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('sonner', () => ({
|
||||||
|
toast: {
|
||||||
|
error: vi.fn(),
|
||||||
|
loading: vi.fn(),
|
||||||
|
success: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
async function flushEffects() {
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ProjectDetailsPage project context', () => {
|
||||||
|
let container: HTMLDivElement
|
||||||
|
let root: ReturnType<typeof createRoot>
|
||||||
|
let queryClient: QueryClient
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement('div')
|
||||||
|
document.body.appendChild(container)
|
||||||
|
root = createRoot(container)
|
||||||
|
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
|
vi.mocked(getProjectFullConfig).mockResolvedValue({
|
||||||
|
project: {
|
||||||
|
id: 'project-t10',
|
||||||
|
name: 'T10',
|
||||||
|
client: 'Cliente T10',
|
||||||
|
operator_name: 'Operadora T10',
|
||||||
|
contract_number: 'CTR-T10',
|
||||||
|
is_active: true,
|
||||||
|
created_at: '2026-06-20T00:00:00Z',
|
||||||
|
},
|
||||||
|
assets: [{
|
||||||
|
asset: {
|
||||||
|
id: 'asset-1',
|
||||||
|
project_id: 'project-t10',
|
||||||
|
code: 'WELL-01',
|
||||||
|
name: 'Pozo uno',
|
||||||
|
asset_type: 'POZO',
|
||||||
|
is_active: true,
|
||||||
|
metadata: { field: 'Norte' },
|
||||||
|
external_api_url: 'http://127.0.0.1:5020',
|
||||||
|
is_collector_enabled: false,
|
||||||
|
created_at: '2026-06-20T00:00:00Z',
|
||||||
|
updated_at: '2026-06-20T00:00:00Z',
|
||||||
|
},
|
||||||
|
devices: [],
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
vi.mocked(getAssetTelemetry).mockResolvedValue([{ asset_id: 'asset-1', time: '2026-06-21T12:00:00Z', data: { value: 10 } }])
|
||||||
|
vi.mocked(listDeployments).mockResolvedValue([])
|
||||||
|
vi.mocked(updateAsset).mockResolvedValue({
|
||||||
|
id: 'asset-1',
|
||||||
|
project_id: 'project-t10',
|
||||||
|
code: 'WELL-01',
|
||||||
|
name: 'Pozo uno',
|
||||||
|
asset_type: 'POZO',
|
||||||
|
is_active: true,
|
||||||
|
metadata: { field: 'Norte' },
|
||||||
|
external_api_url: 'http://127.0.0.1:5020',
|
||||||
|
is_collector_enabled: true,
|
||||||
|
created_at: '2026-06-20T00:00:00Z',
|
||||||
|
updated_at: '2026-06-20T00:00:00Z',
|
||||||
|
})
|
||||||
|
vi.mocked(deploySystem).mockResolvedValue({ message: 'ok', flows_generated: true })
|
||||||
|
vi.mocked(getProject).mockResolvedValue({
|
||||||
|
id: 'project-t10',
|
||||||
|
name: 'T10',
|
||||||
|
client: 'Cliente T10',
|
||||||
|
operator_name: 'Operadora T10',
|
||||||
|
contract_number: 'CTR-T10',
|
||||||
|
description: 'Proyecto T10',
|
||||||
|
is_active: true,
|
||||||
|
installer_status: 'NOT_STARTED',
|
||||||
|
created_at: '2026-06-20T00:00:00Z',
|
||||||
|
})
|
||||||
|
useAuthStore.setState({
|
||||||
|
accessToken: 'token-1',
|
||||||
|
token: 'token-1',
|
||||||
|
activeProjectId: null,
|
||||||
|
projects: [
|
||||||
|
{ id: 'project-t10', name: 'T10' },
|
||||||
|
{ id: 'project-other', name: 'Otro' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount()
|
||||||
|
})
|
||||||
|
queryClient.clear()
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
vi.clearAllMocks()
|
||||||
|
useAuthStore.setState({
|
||||||
|
accessToken: null,
|
||||||
|
token: null,
|
||||||
|
activeProjectId: null,
|
||||||
|
projects: [],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('syncs the URL project as active context for subsequent dashboard navigation', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter initialEntries={['/app/admin/projects/project-t10']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/app/admin/projects/:id" element={<ProjectDetailsPage />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
await flushEffects()
|
||||||
|
|
||||||
|
expect(useAuthStore.getState().activeProjectId).toBe('project-t10')
|
||||||
|
expect(getProjectFullConfig).toHaveBeenCalledWith('project-t10')
|
||||||
|
expect(getAssetTelemetry).toHaveBeenCalledWith('asset-1', 1)
|
||||||
|
expect(container.textContent).toContain('Activos mock: 1')
|
||||||
|
expect(container.textContent).toContain('Configs mock: 1')
|
||||||
|
expect(container.textContent).toContain('Telemetry mock: 2026-06-21T12:00:00Z')
|
||||||
|
expect(getProject).toHaveBeenCalledWith('project-t10')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('enables a well collector through the existing asset update and project deploy APIs', async () => {
|
||||||
|
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter initialEntries={['/app/admin/projects/project-t10']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/app/admin/projects/:id" element={<ProjectDetailsPage />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
await flushEffects()
|
||||||
|
|
||||||
|
const button = Array.from(container.querySelectorAll('button')).find((item) =>
|
||||||
|
item.textContent?.includes('Habilitar colector mock')
|
||||||
|
)
|
||||||
|
expect(button).toBeTruthy()
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
button?.click()
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(updateAsset).toHaveBeenCalledWith('asset-1', expect.objectContaining({
|
||||||
|
name: 'Pozo uno',
|
||||||
|
is_active: true,
|
||||||
|
is_collector_enabled: true,
|
||||||
|
external_api_url: 'http://127.0.0.1:5020',
|
||||||
|
}))
|
||||||
|
expect(deploySystem).toHaveBeenCalledWith('project-t10')
|
||||||
|
expect(getProjectFullConfig).toHaveBeenCalledTimes(2)
|
||||||
|
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ['admin-sidebar-project-wells'] })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows saved-but-not-deployed feedback and reloads after deploy failure', async () => {
|
||||||
|
vi.mocked(deploySystem).mockRejectedValueOnce(new Error('edge agent unreachable'))
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<MemoryRouter initialEntries={['/app/admin/projects/project-t10']}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/app/admin/projects/:id" element={<ProjectDetailsPage />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
await flushEffects()
|
||||||
|
|
||||||
|
const button = Array.from(container.querySelectorAll('button')).find((item) =>
|
||||||
|
item.textContent?.includes('Habilitar colector mock')
|
||||||
|
)
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
button?.click()
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(updateAsset).toHaveBeenCalledWith('asset-1', expect.objectContaining({ is_collector_enabled: true }))
|
||||||
|
expect(deploySystem).toHaveBeenCalledWith('project-t10')
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('Cambio guardado, pero no desplegado', {
|
||||||
|
description: 'edge agent unreachable',
|
||||||
|
})
|
||||||
|
expect(getProjectFullConfig).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -3,8 +3,21 @@ import { useAuthStore } from '@/features/auth/stores/auth-store'
|
|||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
import { useQueryClient } from '@tanstack/react-query'
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { listAssets, createAsset, EdgeAsset } from '../api/wells-api'
|
import { createAsset, updateAsset, type EdgeAsset } from '../api/wells-api'
|
||||||
import { getProject, EdgeProject, deploySystem, listDeployments, EdgeDeployment, rebuildInstaller, getLinuxInstallScript, getLinuxInstallToken, deleteProject } from '../api/projects-api'
|
import { getAssetTelemetry, type TelemetryRecord } from '../api/telemetry-api'
|
||||||
|
import {
|
||||||
|
deleteProject,
|
||||||
|
deploySystem,
|
||||||
|
getLinuxInstallScript,
|
||||||
|
getLinuxInstallToken,
|
||||||
|
getProject,
|
||||||
|
getProjectFullConfig,
|
||||||
|
listDeployments,
|
||||||
|
rebuildInstaller,
|
||||||
|
type EdgeAssetFullConfig,
|
||||||
|
type EdgeDeployment,
|
||||||
|
type EdgeProject,
|
||||||
|
} from '../api/projects-api'
|
||||||
import { createTelemetryWsTicket } from '@/features/admin/telemetry/api/telemetry-ws-api'
|
import { createTelemetryWsTicket } from '@/features/admin/telemetry/api/telemetry-ws-api'
|
||||||
import {
|
import {
|
||||||
Loader2, Download, CheckCircle2, AlertCircle, Terminal, Copy, CheckCheck, Upload
|
Loader2, Download, CheckCircle2, AlertCircle, Terminal, Copy, CheckCheck, Upload
|
||||||
@@ -19,8 +32,12 @@ export default function ProjectDetailsPage() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const token = useAuthStore(s => s.token)
|
const token = useAuthStore(s => s.token)
|
||||||
|
const activeProjectId = useAuthStore(s => s.activeProjectId)
|
||||||
|
const setActiveProject = useAuthStore(s => s.setActiveProject)
|
||||||
const [project, setProject] = useState<EdgeProject | null>(null)
|
const [project, setProject] = useState<EdgeProject | null>(null)
|
||||||
const [assets, setAssets] = useState<EdgeAsset[]>([])
|
const [assets, setAssets] = useState<EdgeAsset[]>([])
|
||||||
|
const [assetConfigs, setAssetConfigs] = useState<EdgeAssetFullConfig[]>([])
|
||||||
|
const [latestTelemetryByAssetId, setLatestTelemetryByAssetId] = useState<Record<string, TelemetryRecord | undefined>>({})
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||||
const [newWell, setNewWell] = useState({ name: '', code: '' })
|
const [newWell, setNewWell] = useState({ name: '', code: '' })
|
||||||
@@ -34,6 +51,7 @@ export default function ProjectDetailsPage() {
|
|||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
const [isDeletingProject, setIsDeletingProject] = useState(false)
|
const [isDeletingProject, setIsDeletingProject] = useState(false)
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||||
|
const [togglingCollectorAssetId, setTogglingCollectorAssetId] = useState<string | null>(null)
|
||||||
|
|
||||||
const [showImportModal, setShowImportModal] = useState(false)
|
const [showImportModal, setShowImportModal] = useState(false)
|
||||||
const [importProtocol, setImportProtocol] = useState<'MODBUS_TCP' | 'S7' | 'OPC_UA' | 'MQTT_SPARKPLUG_B' | 'SQL_DB'>('MODBUS_TCP')
|
const [importProtocol, setImportProtocol] = useState<'MODBUS_TCP' | 'S7' | 'OPC_UA' | 'MQTT_SPARKPLUG_B' | 'SQL_DB'>('MODBUS_TCP')
|
||||||
@@ -115,11 +133,29 @@ export default function ProjectDetailsPage() {
|
|||||||
// Usamos un Ref para el estado previo para evitar stale closures en el polling
|
// Usamos un Ref para el estado previo para evitar stale closures en el polling
|
||||||
const prevStatusRef = useRef<string | null>(null)
|
const prevStatusRef = useRef<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id && activeProjectId !== id) {
|
||||||
|
setActiveProject(id)
|
||||||
|
}
|
||||||
|
}, [activeProjectId, id, setActiveProject])
|
||||||
|
|
||||||
const loadAssets = async () => {
|
const loadAssets = async () => {
|
||||||
if (!id) return
|
if (!id) return
|
||||||
try {
|
try {
|
||||||
const data = await listAssets(id)
|
const config = await getProjectFullConfig(id)
|
||||||
setAssets(data)
|
setAssets(config.assets.map((item) => item.asset))
|
||||||
|
setAssetConfigs(config.assets)
|
||||||
|
const telemetryEntries = await Promise.all(
|
||||||
|
config.assets.map(async ({ asset }) => {
|
||||||
|
try {
|
||||||
|
const records = await getAssetTelemetry(asset.id, 1)
|
||||||
|
return [asset.id, records[0]] as const
|
||||||
|
} catch {
|
||||||
|
return [asset.id, undefined] as const
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
setLatestTelemetryByAssetId(Object.fromEntries(telemetryEntries))
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(err.message)
|
console.error(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -269,6 +305,41 @@ export default function ProjectDetailsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleToggleAssetCollector = async (asset: EdgeAsset) => {
|
||||||
|
if (!id || togglingCollectorAssetId) return
|
||||||
|
const nextCollectorState = !asset.is_collector_enabled
|
||||||
|
setTogglingCollectorAssetId(asset.id)
|
||||||
|
let persistedCollectorState = false
|
||||||
|
try {
|
||||||
|
await updateAsset(asset.id, {
|
||||||
|
name: asset.name,
|
||||||
|
is_active: asset.is_active,
|
||||||
|
metadata: asset.metadata,
|
||||||
|
external_api_url: asset.external_api_url,
|
||||||
|
external_api_key: asset.external_api_key,
|
||||||
|
is_collector_enabled: nextCollectorState,
|
||||||
|
last_calibration_date: asset.last_calibration_date || null,
|
||||||
|
next_calibration_date: asset.next_calibration_date || null,
|
||||||
|
calibration_certificate_url: asset.calibration_certificate_url || null,
|
||||||
|
})
|
||||||
|
persistedCollectorState = true
|
||||||
|
await deploySystem(id)
|
||||||
|
toast.success(nextCollectorState ? 'Colector habilitado' : 'Colector deshabilitado', {
|
||||||
|
description: 'La configuración actualizada fue desplegada al agente edge.',
|
||||||
|
})
|
||||||
|
await loadAssets()
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-sidebar-project-wells'] })
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(
|
||||||
|
persistedCollectorState ? 'Cambio guardado, pero no desplegado' : 'Error al guardar cambio de colector',
|
||||||
|
{ description: err.message }
|
||||||
|
)
|
||||||
|
await loadAssets()
|
||||||
|
} finally {
|
||||||
|
setTogglingCollectorAssetId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleRebuildInstaller = async () => {
|
const handleRebuildInstaller = async () => {
|
||||||
if (!id) return
|
if (!id) return
|
||||||
try {
|
try {
|
||||||
@@ -402,6 +473,8 @@ export default function ProjectDetailsPage() {
|
|||||||
project={project}
|
project={project}
|
||||||
projectId={id}
|
projectId={id}
|
||||||
assets={assets}
|
assets={assets}
|
||||||
|
assetConfigs={assetConfigs}
|
||||||
|
latestTelemetryByAssetId={latestTelemetryByAssetId}
|
||||||
deploying={deploying}
|
deploying={deploying}
|
||||||
deleting={isDeletingProject}
|
deleting={isDeletingProject}
|
||||||
onDownloadInstaller={handleDownloadInstaller}
|
onDownloadInstaller={handleDownloadInstaller}
|
||||||
@@ -412,6 +485,8 @@ export default function ProjectDetailsPage() {
|
|||||||
onCreateWell={() => setShowCreateModal(true)}
|
onCreateWell={() => setShowCreateModal(true)}
|
||||||
onRequestDelete={() => setShowDeleteDialog(true)}
|
onRequestDelete={() => setShowDeleteDialog(true)}
|
||||||
onConfigureAsset={setSelectedAsset}
|
onConfigureAsset={setSelectedAsset}
|
||||||
|
onToggleAssetCollector={handleToggleAssetCollector}
|
||||||
|
togglingCollectorAssetId={togglingCollectorAssetId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showCreateModal && (
|
{showCreateModal && (
|
||||||
|
|||||||
Reference in New Issue
Block a user