4 Commits

9 changed files with 907 additions and 148 deletions

View File

@@ -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')

View File

@@ -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>

View File

@@ -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)

View File

@@ -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 })

View File

@@ -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>
)} )}

View File

@@ -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)
}) })
}) })

View File

@@ -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>

View File

@@ -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)
})
})

View File

@@ -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 && (