From c129337a531ce1f7a9f55a282d713dddf0f91ba8 Mon Sep 17 00:00:00 2001 From: KinshukSS2 Date: Tue, 17 Mar 2026 22:52:56 +0530 Subject: [PATCH 1/4] Add time-travel prototype UI with temporal context, warnings, and commit explorer --- ui/__tests__/temporal-badge.test.tsx | 54 +++++++ ui/__tests__/temporal-context.test.tsx | 64 ++++++++ ui/__tests__/temporal-mode-switch.test.tsx | 60 ++++++++ ui/app/api/commits/route.ts | 100 ++++++++++++ ui/app/api/datastreams/route.ts | 71 +++++++++ ui/app/api/things/route.ts | 76 ++++++++++ ui/app/commits/page.tsx | 128 ++++++++++++++++ ui/app/datastreams/page.tsx | 21 +++ ui/app/layout.tsx | 15 +- ui/app/things/page.tsx | 23 +++ ui/components/PhantomEditWarning.tsx | 26 ++++ ui/components/TemporalBadge.tsx | 62 ++++++++ ui/components/TemporalConflictWarning.tsx | 31 ++++ ui/components/TemporalModeSwitch.tsx | 98 ++++++++++++ ui/components/bars/userbar.tsx | 3 + ui/components/hooks/useTemporalQuery.tsx | 61 ++++++++ ui/config/site.ts | 7 + ui/context/EntitiesContext.tsx | 35 +++-- ui/context/TemporalContext.tsx | 167 +++++++++++++++++++++ ui/jest.config.cjs | 26 ++++ ui/jest.setup.ts | 26 ++++ ui/locales/en/translation.json | 3 +- ui/locales/it/translation.json | 3 +- ui/package.json | 14 +- ui/server/api.tsx | 13 +- ui/server/temporal.ts | 45 ++++++ ui/types/temporal.ts | 50 ++++++ 27 files changed, 1262 insertions(+), 20 deletions(-) create mode 100644 ui/__tests__/temporal-badge.test.tsx create mode 100644 ui/__tests__/temporal-context.test.tsx create mode 100644 ui/__tests__/temporal-mode-switch.test.tsx create mode 100644 ui/app/api/commits/route.ts create mode 100644 ui/app/api/datastreams/route.ts create mode 100644 ui/app/api/things/route.ts create mode 100644 ui/app/commits/page.tsx create mode 100644 ui/components/PhantomEditWarning.tsx create mode 100644 ui/components/TemporalBadge.tsx create mode 100644 ui/components/TemporalConflictWarning.tsx create mode 100644 ui/components/TemporalModeSwitch.tsx create mode 100644 ui/components/hooks/useTemporalQuery.tsx create mode 100644 ui/context/TemporalContext.tsx create mode 100644 ui/jest.config.cjs create mode 100644 ui/jest.setup.ts create mode 100644 ui/server/temporal.ts create mode 100644 ui/types/temporal.ts diff --git a/ui/__tests__/temporal-badge.test.tsx b/ui/__tests__/temporal-badge.test.tsx new file mode 100644 index 00000000..64efaa7a --- /dev/null +++ b/ui/__tests__/temporal-badge.test.tsx @@ -0,0 +1,54 @@ +/* + * Copyright 2025 SUPSI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { render, screen } from '@testing-library/react' +import React from 'react' + +import TemporalBadge from '@/components/TemporalBadge' + +const resetMock = jest.fn() + +const temporalMock = { + mode: 'current', + asOf: null, + fromTo: null, + reset: resetMock, +} + +jest.mock('@/context/TemporalContext', () => ({ + useTemporal: () => temporalMock, +})) + +describe('TemporalBadge', () => { + it('renders live badge for current mode', () => { + temporalMock.mode = 'current' + render() + expect(screen.getByText(/Live/i)).toBeInTheDocument() + }) + + it('renders as-of badge text', () => { + temporalMock.mode = 'as_of' + temporalMock.asOf = '2024-02-01T10:30:00Z' + render() + expect(screen.getByTestId('temporal-badge-as-of')).toBeInTheDocument() + }) + + it('renders from-to badge text', () => { + temporalMock.mode = 'from_to' + temporalMock.fromTo = ['2024-01-01T00:00:00Z', '2024-02-01T00:00:00Z'] + render() + expect(screen.getByTestId('temporal-badge-from-to')).toBeInTheDocument() + }) +}) diff --git a/ui/__tests__/temporal-context.test.tsx b/ui/__tests__/temporal-context.test.tsx new file mode 100644 index 00000000..01c19a65 --- /dev/null +++ b/ui/__tests__/temporal-context.test.tsx @@ -0,0 +1,64 @@ +/* + * Copyright 2025 SUPSI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { act, render, screen } from '@testing-library/react' +import React from 'react' + +import { TemporalProvider, useTemporal } from '@/context/TemporalContext' + +const replaceMock = jest.fn() +const paramsState = new URLSearchParams('') + +jest.mock('next/navigation', () => ({ + useRouter: () => ({ replace: replaceMock }), + usePathname: () => '/things', + useSearchParams: () => paramsState, +})) + +function Consumer() { + const temporal = useTemporal() + return ( + <> + {temporal.mode} + + + + ) +} + +describe('TemporalContext', () => { + it('starts in current mode and supports as_of + reset', async () => { + render( + + + + ) + + expect(screen.getByTestId('mode')).toHaveTextContent('current') + + await act(async () => { + screen.getByText('setAsOf').click() + }) + + expect(screen.getByTestId('mode')).toHaveTextContent('as_of') + + await act(async () => { + screen.getByText('reset').click() + }) + + expect(screen.getByTestId('mode')).toHaveTextContent('current') + expect(replaceMock).toHaveBeenCalled() + }) +}) diff --git a/ui/__tests__/temporal-mode-switch.test.tsx b/ui/__tests__/temporal-mode-switch.test.tsx new file mode 100644 index 00000000..25eb4814 --- /dev/null +++ b/ui/__tests__/temporal-mode-switch.test.tsx @@ -0,0 +1,60 @@ +/* + * Copyright 2025 SUPSI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { render, screen } from '@testing-library/react' +import React from 'react' + +import TemporalModeSwitch from '@/components/TemporalModeSwitch' + +const contextMock = { + mode: 'current', + asOf: null, + fromTo: null, + setMode: jest.fn(), + setAsOf: jest.fn(), + setFromTo: jest.fn(), +} + +jest.mock('@/context/TemporalContext', () => ({ + useTemporal: () => contextMock, +})) + +describe('TemporalModeSwitch', () => { + it('renders all three mode tabs', () => { + contextMock.mode = 'current' + render() + + expect(screen.getByText('Current')).toBeInTheDocument() + expect(screen.getByText('As-of')).toBeInTheDocument() + expect(screen.getByText('From-to')).toBeInTheDocument() + }) + + it('shows as-of input in as_of mode', () => { + contextMock.mode = 'as_of' + contextMock.asOf = '2024-02-01T10:30:00Z' + render() + + expect(screen.getByLabelText('As-of timestamp')).toBeInTheDocument() + }) + + it('shows two datetime inputs in from_to mode', () => { + contextMock.mode = 'from_to' + contextMock.fromTo = ['2024-01-01T00:00:00Z', '2024-02-01T00:00:00Z'] + render() + + expect(screen.getByLabelText('From')).toBeInTheDocument() + expect(screen.getByLabelText('To')).toBeInTheDocument() + }) +}) diff --git a/ui/app/api/commits/route.ts b/ui/app/api/commits/route.ts new file mode 100644 index 00000000..f3ebb09f --- /dev/null +++ b/ui/app/api/commits/route.ts @@ -0,0 +1,100 @@ +/* + * Copyright 2025 SUPSI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { NextResponse } from 'next/server' + +import { CommitItem } from '@/types/temporal' + +const COMMITS: CommitItem[] = [ + { + id: 1, + author: 'admin', + message: 'Initial setup of weather station', + date: '2023-01-15T09:00:00Z', + actionType: 'CREATE', + affectedEntities: ['Thing', 'Location', 'Datastream'], + }, + { + id: 2, + author: 'operator1', + message: 'Calibration update for temperature sensor', + date: '2023-06-20T14:30:00Z', + actionType: 'UPDATE', + affectedEntities: ['Sensor'], + }, + { + id: 3, + author: 'admin', + message: 'Added air quality monitoring node', + date: '2024-02-10T11:00:00Z', + actionType: 'CREATE', + affectedEntities: ['Thing', 'Sensor', 'Datastream'], + }, + { + id: 4, + author: 'operator2', + message: 'Corrected station location metadata', + date: '2024-03-05T16:45:00Z', + actionType: 'UPDATE', + affectedEntities: ['Location'], + }, + { + id: 5, + author: 'admin', + message: 'Upgraded weather station firmware description', + date: '2024-06-01T08:00:00Z', + actionType: 'UPDATE', + affectedEntities: ['Thing'], + }, + { + id: 6, + author: 'operator1', + message: 'Decommissioned old humidity sensor', + date: '2024-08-15T13:00:00Z', + actionType: 'DELETE', + affectedEntities: ['Sensor', 'Datastream'], + }, +] + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url) + const actionType = searchParams.get('actionType') + const author = searchParams.get('author') + const from = searchParams.get('from') + const to = searchParams.get('to') + + let items = [...COMMITS] + + if (actionType) { + items = items.filter((item) => item.actionType === actionType) + } + + if (author) { + const normalized = author.toLowerCase() + items = items.filter((item) => item.author.toLowerCase().includes(normalized)) + } + + if (from) { + const fromDate = new Date(from) + items = items.filter((item) => new Date(item.date) >= fromDate) + } + + if (to) { + const toDate = new Date(to) + items = items.filter((item) => new Date(item.date) <= toDate) + } + + return NextResponse.json({ value: items }) +} diff --git a/ui/app/api/datastreams/route.ts b/ui/app/api/datastreams/route.ts new file mode 100644 index 00000000..b9dc7a0c --- /dev/null +++ b/ui/app/api/datastreams/route.ts @@ -0,0 +1,71 @@ +/* + * Copyright 2025 SUPSI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { NextResponse } from 'next/server' + +const CURRENT_DATASTREAMS = [ + { + '@iot.id': 1, + name: 'Temperature', + description: 'Air temp at 2m (updated metadata)', + observationType: 'double', + phenomenonTime: '2023-01-01T00:00:00Z/2024-12-31T23:59:59Z', + }, + { + '@iot.id': 2, + name: 'Humidity', + description: 'Relative humidity', + observationType: 'double', + phenomenonTime: '2023-06-01T00:00:00Z/2024-12-31T23:59:59Z', + }, +] + +const HISTORICAL_DATASTREAMS = [ + { + '@iot.id': 1, + name: 'Temperature', + description: 'Air temp at 2m', + observationType: 'double', + phenomenonTime: '2023-01-01T00:00:00Z/2024-12-31T23:59:59Z', + systemTimeValidity: '[2023-01-01, 2024-06-01)', + }, +] + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url) + const asOf = searchParams.get('$as_of') + const fromTo = searchParams.get('$from_to') + + if (asOf) { + const asOfDate = new Date(asOf) + if (asOfDate < new Date('2023-01-01T00:00:00Z')) { + return NextResponse.json({ value: [] }) + } + if (asOfDate < new Date('2024-06-01T00:00:00Z')) { + return NextResponse.json({ value: HISTORICAL_DATASTREAMS }) + } + return NextResponse.json({ value: CURRENT_DATASTREAMS }) + } + + if (fromTo) { + const [from] = fromTo.split(',') + if (from && new Date(from) < new Date('2023-01-01T00:00:00Z')) { + return NextResponse.json({ value: [] }) + } + return NextResponse.json({ value: HISTORICAL_DATASTREAMS }) + } + + return NextResponse.json({ value: CURRENT_DATASTREAMS }) +} diff --git a/ui/app/api/things/route.ts b/ui/app/api/things/route.ts new file mode 100644 index 00000000..084ada42 --- /dev/null +++ b/ui/app/api/things/route.ts @@ -0,0 +1,76 @@ +/* + * Copyright 2025 SUPSI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { NextResponse } from 'next/server' + +const CURRENT_THINGS = [ + { + '@iot.id': 1, + name: 'Weather Station Alpha', + description: 'Main campus station (upgraded 2024)', + }, + { + '@iot.id': 2, + name: 'River Gauge Beta', + description: 'River level monitoring', + }, + { + '@iot.id': 3, + name: 'Air Quality Gamma', + description: 'PM2.5 sensor network node', + }, +] + +const HISTORICAL_THINGS = [ + { + '@iot.id': 1, + name: 'Weather Station Alpha', + description: 'Main campus station (original)', + systemTimeValidity: '[2023-01-15, 2024-06-01)', + }, + { + '@iot.id': 2, + name: 'River Gauge Beta', + description: 'River level monitoring', + systemTimeValidity: '[2023-03-20, infinity)', + }, +] + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url) + const asOf = searchParams.get('$as_of') + const fromTo = searchParams.get('$from_to') + + if (asOf) { + const asOfDate = new Date(asOf) + if (asOfDate < new Date('2023-01-01T00:00:00Z')) { + return NextResponse.json({ value: [] }) + } + if (asOfDate < new Date('2024-06-01T00:00:00Z')) { + return NextResponse.json({ value: HISTORICAL_THINGS }) + } + return NextResponse.json({ value: CURRENT_THINGS }) + } + + if (fromTo) { + const [from] = fromTo.split(',') + if (from && new Date(from) < new Date('2023-01-01T00:00:00Z')) { + return NextResponse.json({ value: [] }) + } + return NextResponse.json({ value: HISTORICAL_THINGS }) + } + + return NextResponse.json({ value: CURRENT_THINGS }) +} diff --git a/ui/app/commits/page.tsx b/ui/app/commits/page.tsx new file mode 100644 index 00000000..58937f50 --- /dev/null +++ b/ui/app/commits/page.tsx @@ -0,0 +1,128 @@ +'use client' + +/* + * Copyright 2025 SUPSI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Card } from '@heroui/card' +import { Chip } from '@heroui/chip' +import { Input } from '@heroui/input' +import { Select, SelectItem } from '@heroui/select' +import dayjs from 'dayjs' +import * as React from 'react' + +import { useRouter } from 'next/navigation' + +import { SecNavbar } from '@/components/bars/secNavbar' + +import { useTemporal } from '@/context/TemporalContext' + +import { CommitItem } from '@/types/temporal' + +const actionColorMap = { + CREATE: 'success', + UPDATE: 'warning', + DELETE: 'danger', +} as const + +export default function CommitsPage() { + const router = useRouter() + const { setAsOf } = useTemporal() + const [items, setItems] = React.useState([]) + const [actionType, setActionType] = React.useState('') + const [author, setAuthor] = React.useState('') + const [loading, setLoading] = React.useState(true) + + React.useEffect(() => { + const params = new URLSearchParams() + if (actionType) params.set('actionType', actionType) + if (author) params.set('author', author) + + const url = params.toString() + ? `/api/commits?${params.toString()}` + : '/api/commits' + + setLoading(true) + fetch(url) + .then((response) => response.json()) + .then((payload) => setItems(payload?.value || [])) + .finally(() => setLoading(false)) + }, [actionType, author]) + + return ( +
+
+ +
+
+ + setAuthor(event.target.value)} + /> +
+ + {loading &&

Loading...

} + +
+ {items.map((item) => ( + +
+
+

{item.message}

+

+ {item.author} · {dayjs(item.date).format('MMM D, YYYY HH:mm')} +

+
+ + {item.actionType} + +
+
+ {item.affectedEntities.map((entity) => ( + + {entity} + + ))} +
+ {item.actionType === 'UPDATE' && ( + + )} +
+ ))} +
+
+ ) +} diff --git a/ui/app/datastreams/page.tsx b/ui/app/datastreams/page.tsx index e590b1f2..3facf235 100644 --- a/ui/app/datastreams/page.tsx +++ b/ui/app/datastreams/page.tsx @@ -27,6 +27,9 @@ import { useRouter, useSearchParams } from 'next/navigation' import { LoadingScreen } from '@/components/LoadingScreen' import MapWrapper from '@/components/MapWrapper' +import PhantomEditWarning from '@/components/PhantomEditWarning' +import TemporalConflictWarning from '@/components/TemporalConflictWarning' +import TemporalModeSwitch from '@/components/TemporalModeSwitch' import { EntityActions } from '@/components/entity/EntityActions' import { EntityList } from '@/components/entity/EntityList' import { useEnrichedDatastreams } from '@/components/hooks/useEnrichedDatastreams' @@ -37,8 +40,11 @@ import { siteConfig } from '@/config/site' import { useAuth } from '@/context/AuthContext' import { useEntities } from '@/context/EntitiesContext' +import { useTemporal } from '@/context/TemporalContext' import { useTimezone } from '@/context/TimezoneContext' +import { appendTemporalParams } from '@/server/temporal' + import { useDatastreamCRUDHandler } from './DatastreamCRUDHandler' import DatastreamCreator from './DatastreamCreator' import { buildDatastreamFields, delayThresholdOptions } from './utils' @@ -73,6 +79,7 @@ export default function Datastreams() { const [showMap, setShowMap] = React.useState(true) const [split, setSplit] = React.useState(0.5) const { timezone } = useTimezone() + const { mode, asOf, fromTo } = useTemporal() // Date range state const [customStart, setCustomStart] = React.useState(null) const [customEnd, setCustomEnd] = React.useState(null) @@ -353,6 +360,12 @@ export default function Datastreams() { if (loading) return if (entitiesError) return

{entitiesError}

+ const apiPreview = appendTemporalParams(item?.root || '/Datastreams', { + mode, + asOf, + fromTo, + }) + const entityListComponent = ( + +
+ GET {apiPreview} +
+ {mode !== 'current' && } + {mode !== 'current' && filtered.length === 0 && ( + + )}
setAsOf(fromLocalInputValue(event.target.value))} + size="sm" + radius="sm" + /> +
+ )} + + {mode === 'from_to' && ( +
+ + setFromTo([ + fromLocalInputValue(event.target.value) || new Date().toISOString(), + fromTo?.[1] || new Date().toISOString(), + ]) + } + size="sm" + radius="sm" + /> + + setFromTo([ + fromTo?.[0] || new Date(Date.now() - 60 * 60 * 1000).toISOString(), + fromLocalInputValue(event.target.value) || new Date().toISOString(), + ]) + } + size="sm" + radius="sm" + /> +
+ )} +
+ ) +} diff --git a/ui/components/bars/userbar.tsx b/ui/components/bars/userbar.tsx index 0e4145c7..6ba670f9 100644 --- a/ui/components/bars/userbar.tsx +++ b/ui/components/bars/userbar.tsx @@ -36,6 +36,7 @@ import { useTimezone } from '@/context/TimezoneContext' import fetchLogout from '@/server/fetchLogout' +import TemporalBadge from '../TemporalBadge' import { LogoIstSOS } from '../icons' const mainColor = siteConfig.main_color @@ -252,6 +253,8 @@ export default function UserBar({ )} + + {/* User + Language controls */}
{token && ( diff --git a/ui/components/hooks/useTemporalQuery.tsx b/ui/components/hooks/useTemporalQuery.tsx new file mode 100644 index 00000000..afbfd675 --- /dev/null +++ b/ui/components/hooks/useTemporalQuery.tsx @@ -0,0 +1,61 @@ +'use client' + +/* + * Copyright 2025 SUPSI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { useEffect, useMemo, useState } from 'react' + +import { useTemporal } from '@/context/TemporalContext' +import { appendTemporalParams } from '@/server/temporal' + +export function useTemporalQuery(baseUrl: string) { + const { mode, asOf, fromTo } = useTemporal() + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + const activeUrl = useMemo(() => { + return appendTemporalParams(baseUrl, { mode, asOf, fromTo }) + }, [baseUrl, mode, asOf, fromTo]) + + useEffect(() => { + let mounted = true + setLoading(true) + setError(null) + + fetch(activeUrl) + .then((response) => { + if (!response.ok) { + throw new Error(`Error fetching ${activeUrl}: ${response.status}`) + } + return response.json() + }) + .then((payload) => { + if (mounted) setData(payload) + }) + .catch((err) => { + if (mounted) setError(err as Error) + }) + .finally(() => { + if (mounted) setLoading(false) + }) + + return () => { + mounted = false + } + }, [activeUrl]) + + return { data, loading, error, activeUrl } +} diff --git a/ui/config/site.ts b/ui/config/site.ts index 343ab394..4a2bf943 100644 --- a/ui/config/site.ts +++ b/ui/config/site.ts @@ -59,6 +59,13 @@ export const siteConfig = { weight: 1, }, + { + label: 'Commits', + href: '/commits', + root: '/api/commits', + weight: 1, + }, + { label: 'Things', href: '/things', diff --git a/ui/context/EntitiesContext.tsx b/ui/context/EntitiesContext.tsx index 20972510..ecd07de8 100644 --- a/ui/context/EntitiesContext.tsx +++ b/ui/context/EntitiesContext.tsx @@ -22,6 +22,7 @@ import { siteConfig } from '@/config/site' import { fetchData } from '@/server/api' import { useAuth } from './AuthContext' +import { useTemporal } from './TemporalContext' type Entities = { locations: any[] @@ -63,6 +64,7 @@ const EntitiesContext = createContext({ export function EntitiesProvider({ children }: { children: React.ReactNode }) { const { token, loading: authLoading } = useAuth() + const { mode, asOf, fromTo } = useTemporal() const [entities, setEntities] = useState({ locations: [], @@ -97,6 +99,13 @@ export function EntitiesProvider({ children }: { children: React.ReactNode }) { const refetchAll = async () => { if (!token || authLoading) return + + const temporal = { + mode, + asOf, + fromTo, + } + setLoading(true) setError(null) try { @@ -113,22 +122,28 @@ export function EntitiesProvider({ children }: { children: React.ReactNode }) { historicalLocations, network, ] = await Promise.all([ - fetchData(getRoot('Locations'), token).then((d) => d?.value || []), - fetchData(getRoot('Things'), token).then((d) => d?.value || []), - fetchData(getRoot('Sensors'), token).then((d) => d?.value || []), + fetchData(getRoot('Locations'), token, temporal).then( + (d) => d?.value || [] + ), + fetchData(getRoot('Things'), token, temporal).then((d) => d?.value || []), + fetchData(getRoot('Sensors'), token, temporal).then((d) => d?.value || []), // Datastreams with $expand (if configured) - fetchData(datastreamsUrl, token).then((d) => d?.value || []), - fetchData(getRoot('Observations'), token).then((d) => d?.value || []), - fetchData(getRoot('FeaturesOfInterest'), token).then( + fetchData(datastreamsUrl, token, temporal).then((d) => d?.value || []), + fetchData(getRoot('Observations'), token, temporal).then( + (d) => d?.value || [] + ), + fetchData(getRoot('FeaturesOfInterest'), token, temporal).then( + (d) => d?.value || [] + ), + fetchData(getRoot('ObservedProperties'), token, temporal).then( (d) => d?.value || [] ), - fetchData(getRoot('ObservedProperties'), token).then( + fetchData(getRoot('HistoricalLocations'), token, temporal).then( (d) => d?.value || [] ), - fetchData(getRoot('HistoricalLocations'), token).then( + fetchData(getRoot('Networks'), token, temporal).then( (d) => d?.value || [] ), - fetchData(getRoot('Networks'), token).then((d) => d?.value || []), ]) setEntities({ @@ -152,7 +167,7 @@ export function EntitiesProvider({ children }: { children: React.ReactNode }) { useEffect(() => { refetchAll() // eslint-disable-next-line react-hooks/exhaustive-deps - }, [token, authLoading]) + }, [token, authLoading, mode, asOf, fromTo]) return ( void + setAsOf: (asOf: string | null) => void + setFromTo: (fromTo: [string, string] | null) => void + reset: () => void +} + +const defaultState: TemporalState = { + mode: 'current', + asOf: null, + fromTo: null, +} + +const TemporalContext = createContext({ + ...defaultState, + setMode: () => {}, + setAsOf: () => {}, + setFromTo: () => {}, + reset: () => {}, +}) + +function parseTemporalFromUrl(params: URLSearchParams): TemporalState { + const mode = (params.get('temporal_mode') || 'current') as TemporalMode + const asOf = params.get('as_of') + const fromToRaw = params.get('from_to') + const fromTo = fromToRaw?.includes(',') + ? (fromToRaw.split(',').slice(0, 2) as [string, string]) + : null + + if (mode === 'as_of' && asOf) { + return { mode, asOf, fromTo: null } + } + + if (mode === 'from_to' && fromTo) { + return { mode, asOf: null, fromTo } + } + + return defaultState +} + +export function TemporalProvider({ children }: { children: React.ReactNode }) { + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + + const [state, setState] = useState(defaultState) + const [hydrated, setHydrated] = useState(false) + + useEffect(() => { + const next = parseTemporalFromUrl(new URLSearchParams(searchParams.toString())) + setState((prev) => + prev.mode === next.mode && + prev.asOf === next.asOf && + prev.fromTo?.[0] === next.fromTo?.[0] && + prev.fromTo?.[1] === next.fromTo?.[1] + ? prev + : next + ) + setHydrated(true) + }, [searchParams]) + + useEffect(() => { + if (!hydrated) return + + const params = new URLSearchParams(searchParams.toString()) + params.delete('temporal_mode') + params.delete('as_of') + params.delete('from_to') + + if (state.mode !== 'current') { + params.set('temporal_mode', state.mode) + if (state.mode === 'as_of' && state.asOf) { + params.set('as_of', state.asOf) + } + if (state.mode === 'from_to' && state.fromTo) { + params.set('from_to', `${state.fromTo[0]},${state.fromTo[1]}`) + } + } + + const current = searchParams.toString() + const next = params.toString() + if (current !== next) { + const url = next ? `${pathname}?${next}` : pathname + router.replace(url, { scroll: false }) + } + }, [state, hydrated, pathname, router, searchParams]) + + const value = useMemo( + () => ({ + ...state, + setMode: (mode: TemporalMode) => { + if (mode === 'current') { + setState(defaultState) + return + } + + if (mode === 'as_of') { + setState((prev) => ({ + mode, + asOf: prev.asOf || new Date().toISOString(), + fromTo: null, + })) + return + } + + setState((prev) => { + const now = new Date() + const start = new Date(now.getTime() - 60 * 60 * 1000) + return { + mode, + asOf: null, + fromTo: prev.fromTo || [start.toISOString(), now.toISOString()], + } + }) + }, + setAsOf: (asOf: string | null) => { + setState((prev) => ({ + ...prev, + mode: asOf ? 'as_of' : 'current', + asOf, + fromTo: null, + })) + }, + setFromTo: (fromTo: [string, string] | null) => { + setState((prev) => ({ + ...prev, + mode: fromTo ? 'from_to' : 'current', + asOf: null, + fromTo, + })) + }, + reset: () => { + setState(defaultState) + }, + }), + [state] + ) + + return {children} +} + +export function useTemporal() { + return useContext(TemporalContext) +} diff --git a/ui/jest.config.cjs b/ui/jest.config.cjs new file mode 100644 index 00000000..0d9908f0 --- /dev/null +++ b/ui/jest.config.cjs @@ -0,0 +1,26 @@ +/* + * Copyright 2025 SUPSI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +module.exports = { + testEnvironment: 'jsdom', + modulePathIgnorePatterns: ['/.next/'], + transform: { + '^.+\\.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.json' }], + }, + moduleNameMapper: { + '^@/(.*)$': '/$1', + }, + setupFilesAfterEnv: ['/jest.setup.ts'], +} diff --git a/ui/jest.setup.ts b/ui/jest.setup.ts new file mode 100644 index 00000000..18edd1ef --- /dev/null +++ b/ui/jest.setup.ts @@ -0,0 +1,26 @@ +/* + * Copyright 2025 SUPSI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import '@testing-library/jest-dom' + +class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} +} + +if (!global.ResizeObserver) { + ;(global as any).ResizeObserver = ResizeObserverMock +} diff --git a/ui/locales/en/translation.json b/ui/locales/en/translation.json index 15c9dad6..ae6b8593 100644 --- a/ui/locales/en/translation.json +++ b/ui/locales/en/translation.json @@ -38,7 +38,8 @@ "sensors_info": "A Sensor is an instrument that observes a property or phenomenon with the goal of producing an estimate of the value of the property.", "observedproperties_info": "An ObservedProperty specifies the phenomenon of an Observation.", "observations_info": "An Observation is the act of measuring or otherwise determining the value of a property.", - "featuresofinterest_info": "An Observation assigns a value to a phenomenon, which is a property of the FeatureOfInterest (FOI). In IoT, the FOI is often the Location of the Thing (e.g., a thermostat’s location is the living room). In remote sensing, the FOI can be the geographic area or volume being observed." + "featuresofinterest_info": "An Observation assigns a value to a phenomenon, which is a property of the FeatureOfInterest (FOI). In IoT, the FOI is often the Location of the Thing (e.g., a thermostat’s location is the living room). In remote sensing, the FOI can be the geographic area or volume being observed.", + "commits_info": "A timeline of create, update, and delete operations with author, message, and affected entities." }, "datastreams": { "create": "Create Datastream", diff --git a/ui/locales/it/translation.json b/ui/locales/it/translation.json index 3da7de71..33d3d361 100644 --- a/ui/locales/it/translation.json +++ b/ui/locales/it/translation.json @@ -38,7 +38,8 @@ "sensors_info": "Un Sensore è uno strumento che osserva una proprietà o un fenomeno con l'obiettivo di produrre una stima del valore della proprietà.", "observedproperties_info": "Una Proprietà Osservata specifica il fenomeno di un'Osservazione.", "observations_info": "Un'Osservazione è l'atto di misurare o determinare il valore di una proprietà.", - "featuresofinterest_info": "Un'Osservazione assegna un valore a un fenomeno, che è una proprietà della FeatureOfInterest (FOI). Nell'IoT, la FOI è spesso la posizione della Thing (es. la posizione di un termostato è il soggiorno). Nel telerilevamento, la FOI può essere l'area geografica o il volume osservato." + "featuresofinterest_info": "Un'Osservazione assegna un valore a un fenomeno, che è una proprietà della FeatureOfInterest (FOI). Nell'IoT, la FOI è spesso la posizione della Thing (es. la posizione di un termostato è il soggiorno). Nel telerilevamento, la FOI può essere l'area geografica o il volume osservato.", + "commits_info": "Una timeline delle operazioni di creazione, aggiornamento ed eliminazione con autore, messaggio ed entità coinvolte." }, "datastreams": { "create": "Crea Datastream", diff --git a/ui/package.json b/ui/package.json index f6d21443..50649109 100644 --- a/ui/package.json +++ b/ui/package.json @@ -6,7 +6,9 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next typegen", + "test": "jest", + "test:ci": "jest --runInBand --ci" }, "dependencies": { "@heroui/accordion": "2.2.27", @@ -24,8 +26,10 @@ "@heroui/spinner": "2.2.27", "@heroui/switch": "2.2.26", "@heroui/system": "2.4.26", + "@heroui/tabs": "^2.2.29", "@heroui/theme": "2.4.26", "@heroui/tooltip": "2.2.27", + "dayjs": "^1.11.20", "i18next": "25.8.4", "i18next-browser-languagedetector": "8.2.0", "iana-tz-data": "2019.1.0", @@ -45,14 +49,22 @@ }, "devDependencies": { "@tailwindcss/postcss": "4.1.18", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@trivago/prettier-plugin-sort-imports": "6.0.2", + "@types/jest": "^30.0.0", "@types/leaflet.markercluster": "1.5.6", "@types/node": "25.2.3", "@types/proj4": "2.5.6", "@types/react": "19.2.13", + "eslint": "^10.0.3", + "jest": "^30.3.0", + "jest-environment-jsdom": "^30.3.0", "postcss": "8.5.6", "prettier": "3.8.1", "tailwindcss": "4.1.18", + "ts-jest": "^29.4.6", "typescript": "5.9.3" } } diff --git a/ui/server/api.tsx b/ui/server/api.tsx index b34ba5c2..7df709c8 100644 --- a/ui/server/api.tsx +++ b/ui/server/api.tsx @@ -16,9 +16,18 @@ * limitations under the License. */ -export const fetchData = async (endpoint: string, token: string) => { +import { TemporalState } from '@/types/temporal' + +import { appendTemporalParams } from './temporal' + +export const fetchData = async ( + endpoint: string, + token: string, + temporal?: TemporalState +) => { try { - const response = await fetch(endpoint, { + const temporalEndpoint = appendTemporalParams(endpoint, temporal) + const response = await fetch(temporalEndpoint, { method: 'GET', headers: { Authorization: `Bearer ${token}`, diff --git a/ui/server/temporal.ts b/ui/server/temporal.ts new file mode 100644 index 00000000..eee324a6 --- /dev/null +++ b/ui/server/temporal.ts @@ -0,0 +1,45 @@ +/* + * Copyright 2025 SUPSI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TemporalState } from '@/types/temporal' + +function isAbsoluteUrl(url: string) { + return /^https?:\/\//i.test(url) +} + +export function appendTemporalParams( + endpoint: string, + temporal?: TemporalState +): string { + if (!temporal || temporal.mode === 'current') return endpoint + + const absolute = isAbsoluteUrl(endpoint) + const base = absolute ? undefined : 'http://localhost' + const url = new URL(endpoint, base) + + if (temporal.mode === 'as_of' && temporal.asOf) { + url.searchParams.set('$as_of', temporal.asOf) + url.searchParams.delete('$from_to') + } + + if (temporal.mode === 'from_to' && temporal.fromTo) { + url.searchParams.set('$from_to', `${temporal.fromTo[0]},${temporal.fromTo[1]}`) + url.searchParams.delete('$as_of') + } + + if (absolute) return url.toString() + return `${url.pathname}${url.search}` +} diff --git a/ui/types/temporal.ts b/ui/types/temporal.ts new file mode 100644 index 00000000..d2c17b06 --- /dev/null +++ b/ui/types/temporal.ts @@ -0,0 +1,50 @@ +/* + * Copyright 2025 SUPSI + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export type TemporalMode = 'current' | 'as_of' | 'from_to' + +export interface TemporalState { + mode: TemporalMode + asOf: string | null + fromTo: [string, string] | null +} + +export interface CommitItem { + id: number + author: string + message: string + date: string + actionType: 'CREATE' | 'UPDATE' | 'DELETE' + affectedEntities: string[] +} + +export interface ThingTemporal { + '@iot.id': number + name: string + description: string + properties?: Record + systemTimeValidity?: string +} + +export interface DatastreamTemporal { + '@iot.id': number + name: string + description: string + observationType: string + phenomenonTime?: string + observedArea?: unknown + systemTimeValidity?: string +} From 03fe48cbdaeafc4d6f107bc0e03a333d424a026c Mon Sep 17 00:00:00 2001 From: KinshukSS2 Date: Sun, 29 Mar 2026 20:02:11 +0530 Subject: [PATCH 2/4] feat(ui): update prototype implementation --- ui/app/Home.tsx | 35 ++- ui/app/api/history/activity/route.ts | 39 ++++ ui/app/api/history/route.ts | 206 ++++++++++++++++++ ui/app/commits/page.tsx | 140 +++++++----- ui/app/datastreams/page.tsx | 87 +++++--- ui/app/history/page.tsx | 119 ++++++++++ ui/app/layout.tsx | 5 +- ui/app/things/page.tsx | 77 ++++--- ui/components/PhantomEditWarning.tsx | 2 +- ui/components/TemporalConflictWarning.tsx | 2 +- ui/components/TemporalModeSwitch.tsx | 27 ++- ui/components/icons.tsx | 29 ++- ui/components/layout/Navbar.tsx | 38 +++- ui/components/table/Table.tsx | 16 +- ui/context/TemporalContext.tsx | 79 ++++--- .../history/components/ActivityGraph.tsx | 42 ++++ .../history/components/HistoryDetails.tsx | 109 +++++++++ ui/features/history/types.ts | 61 ++++++ ui/features/map/components/MapMenu.tsx | 20 +- ui/next.config.js | 17 +- ui/styles/globals.css | 50 ++++- 21 files changed, 1009 insertions(+), 191 deletions(-) create mode 100644 ui/app/api/history/activity/route.ts create mode 100644 ui/app/api/history/route.ts create mode 100644 ui/app/history/page.tsx create mode 100644 ui/features/history/components/ActivityGraph.tsx create mode 100644 ui/features/history/components/HistoryDetails.tsx create mode 100644 ui/features/history/types.ts diff --git a/ui/app/Home.tsx b/ui/app/Home.tsx index c800b7b7..0a0dce19 100644 --- a/ui/app/Home.tsx +++ b/ui/app/Home.tsx @@ -18,11 +18,14 @@ import FormModal from '@/features/forms/components/FormModal' import LeafletMap from '@/features/map/components/LeafletMap' import ObservationGraph from '@/features/observations/components/ObservationGraph' import { getObservationsByDatastream } from '@/services/observations' +import { Button } from '@heroui/button' import { Card } from '@heroui/card' import { Tab, Tabs } from '@heroui/tabs' import dayjs from 'dayjs' import { useMemo, useRef, useState } from 'react' +import { useRouter } from 'next/navigation' + import { useAuth } from '@/context/AuthContext' type BottomTabKey = 'table' | 'chart' @@ -46,6 +49,7 @@ export default function Home({ things: any[] selectedNetwork?: string }) { + const router = useRouter() const { token } = useAuth() const [localThings, setLocalThings] = useState(things) @@ -135,7 +139,27 @@ export default function Home({ } return ( -
+
+
+ +
+
+

Monitoring workspace

+

+ Select a station on the map to open stream details and charts. +

+
+ +
+
+
-
+
{ @@ -195,6 +219,11 @@ export default function Home({ } }} variant="underlined" + classNames={{ + tabList: 'bg-[var(--color-surface-elevated)] border border-[var(--color-border)] rounded-lg', + tab: 'text-[var(--color-text-secondary)] data-[selected=true]:text-[var(--color-text-primary)]', + cursor: 'bg-[var(--color-accent)]', + }} >
diff --git a/ui/app/api/history/activity/route.ts b/ui/app/api/history/activity/route.ts new file mode 100644 index 00000000..7eeec36e --- /dev/null +++ b/ui/app/api/history/activity/route.ts @@ -0,0 +1,39 @@ +// Copyright 2026 SUPSI +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { NextResponse } from 'next/server' + +import { ActivityBucket } from '@/features/history/types' + +const DAYS = 28 + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url) + const period = searchParams.get('period') || 'week' + + const today = new Date('2024-08-20T00:00:00Z') + const buckets: ActivityBucket[] = [] + + for (let index = DAYS - 1; index >= 0; index -= 1) { + const date = new Date(today) + date.setUTCDate(today.getUTCDate() - index) + const count = period === 'week' ? (index * 3) % 5 : (index * 7) % 9 + + buckets.push({ + date: date.toISOString().slice(0, 10), + count, + }) + } + + return NextResponse.json({ value: buckets }) +} diff --git a/ui/app/api/history/route.ts b/ui/app/api/history/route.ts new file mode 100644 index 00000000..e38b8216 --- /dev/null +++ b/ui/app/api/history/route.ts @@ -0,0 +1,206 @@ +// Copyright 2026 SUPSI +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { NextResponse } from 'next/server' + +import { + HistoryCommit, + HistoryEntityType, + HistoryResponse, + HistorySnapshot, +} from '@/features/history/types' + +const ENTITY_TYPES: HistoryEntityType[] = [ + 'Thing', + 'Sensor', + 'Datastream', + 'Location', + 'ObservedProperty', + 'FeatureOfInterest', +] + +const SNAPSHOTS: Record = { + Thing: [ + { + id: 1, + label: 'Weather Station Alpha', + description: 'Main station metadata snapshot', + snapshotAt: '2024-06-01T08:00:00Z', + systemTimeValidity: '[2023-01-15, infinity)', + meta: { network: 'Campus', status: 'active' }, + }, + ], + Sensor: [ + { + id: 11, + label: 'Temp Sensor T-2M', + description: 'Sensor calibration profile', + snapshotAt: '2024-03-11T12:00:00Z', + systemTimeValidity: '[2023-01-15, infinity)', + meta: { encodingType: 'application/pdf', model: 'TS-200' }, + }, + ], + Datastream: [ + { + id: 21, + label: 'Temperature', + description: 'Air temperature stream', + snapshotAt: '2024-06-01T08:00:00Z', + systemTimeValidity: '[2023-01-15, infinity)', + meta: { observationType: 'double', uom: '°C' }, + }, + ], + Location: [ + { + id: 31, + label: 'Campus Roof', + description: 'Station location metadata', + snapshotAt: '2024-03-05T16:45:00Z', + systemTimeValidity: '[2023-01-15, infinity)', + meta: { latitude: '46.026', longitude: '8.955' }, + }, + ], + ObservedProperty: [ + { + id: 41, + label: 'Air Temperature', + description: 'Observed property definition', + snapshotAt: '2024-01-17T10:30:00Z', + systemTimeValidity: '[2023-01-15, infinity)', + meta: { definition: 'http://qudt.org/vocab/quantitykind/Temperature' }, + }, + ], + FeatureOfInterest: [ + { + id: 51, + label: 'Monitoring Zone A', + description: 'FOI polygon metadata', + snapshotAt: '2024-02-15T09:10:00Z', + systemTimeValidity: '[2023-01-15, infinity)', + meta: { geometryType: 'Polygon', area: '2.3km²' }, + }, + ], +} + +const COMMITS: HistoryCommit[] = [ + { + id: 1001, + entityType: 'Thing', + entityId: 1, + entityName: 'Weather Station Alpha', + author: 'admin', + message: 'Updated station description after firmware migration', + date: '2024-06-01T08:00:00Z', + actionType: 'UPDATE', + fieldDiff: [{ field: 'description', before: 'Main campus station (original)', after: 'Main campus station (upgraded 2024)' }], + }, + { + id: 1002, + entityType: 'Sensor', + entityId: 11, + entityName: 'Temp Sensor T-2M', + author: 'operator1', + message: 'Adjusted calibration coefficient', + date: '2024-03-11T12:00:00Z', + actionType: 'UPDATE', + fieldDiff: [{ field: 'properties.calibration', before: '1.00', after: '1.02' }], + }, + { + id: 1003, + entityType: 'Datastream', + entityId: 21, + entityName: 'Temperature', + author: 'admin', + message: 'Datastream initialized', + date: '2023-01-15T09:00:00Z', + actionType: 'CREATE', + fieldDiff: [], + }, + { + id: 1004, + entityType: 'Location', + entityId: 31, + entityName: 'Campus Roof', + author: 'operator2', + message: 'Corrected location coordinates precision', + date: '2024-03-05T16:45:00Z', + actionType: 'UPDATE', + fieldDiff: [{ field: 'location.coordinates', before: '[8.954,46.025]', after: '[8.955,46.026]' }], + }, + { + id: 1005, + entityType: 'ObservedProperty', + entityId: 41, + entityName: 'Air Temperature', + author: 'admin', + message: 'Observed property created', + date: '2023-01-15T08:55:00Z', + actionType: 'CREATE', + fieldDiff: [], + }, + { + id: 1006, + entityType: 'FeatureOfInterest', + entityId: 51, + entityName: 'Monitoring Zone A', + author: 'operator1', + message: 'FOI boundary update', + date: '2024-02-15T09:10:00Z', + actionType: 'UPDATE', + fieldDiff: [{ field: 'description', before: 'Zone A', after: 'Monitoring Zone A' }], + }, +] + +function parseEntityType(value: string | null): HistoryEntityType { + if (value && ENTITY_TYPES.includes(value as HistoryEntityType)) { + return value as HistoryEntityType + } + return 'Thing' +} + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url) + const entityType = parseEntityType(searchParams.get('entityType')) + const asOf = searchParams.get('$as_of') + const fromTo = searchParams.get('$from_to') + + let snapshots = [...SNAPSHOTS[entityType]] + let commits = COMMITS.filter((item) => item.entityType === entityType) + + if (asOf) { + const asOfDate = new Date(asOf) + snapshots = snapshots.filter((snapshot) => new Date(snapshot.snapshotAt) <= asOfDate) + commits = commits.filter((commit) => new Date(commit.date) <= asOfDate) + } + + if (fromTo) { + const [fromRaw, toRaw] = fromTo.split(',') + const fromDate = fromRaw ? new Date(fromRaw) : null + const toDate = toRaw ? new Date(toRaw) : null + + commits = commits.filter((commit) => { + const commitDate = new Date(commit.date) + if (fromDate && commitDate < fromDate) return false + if (toDate && commitDate > toDate) return false + return true + }) + } + + const response: HistoryResponse = { + entityType, + value: snapshots, + commits, + } + + return NextResponse.json(response) +} diff --git a/ui/app/commits/page.tsx b/ui/app/commits/page.tsx index b533c6e3..9aa5f33a 100644 --- a/ui/app/commits/page.tsx +++ b/ui/app/commits/page.tsx @@ -59,65 +59,93 @@ export default function CommitsPage() { }, [actionType, author]) return ( -
-

Commits

-
- - setAuthor(event.target.value)} - /> -
+
+
+
+

Commits

+

Track changes by author and action type, then pivot into temporal entity views.

+
- {loading &&

Loading...

} + +
+ + setAuthor(event.target.value)} + classNames={{ + label: 'text-[var(--color-text-secondary)]', + inputWrapper: + 'bg-[var(--color-surface-elevated)] border border-[var(--color-border)] text-[var(--color-text-primary)]', + input: 'text-[var(--color-text-primary)] placeholder:text-[var(--color-text-secondary)]', + }} + /> +
+
-
- {items.map((item) => ( - -
-
-

{item.message}

-

- {item.author} · {dayjs(item.date).format('MMM D, YYYY HH:mm')} -

-
- - {item.actionType} - -
-
- {item.affectedEntities.map((entity) => ( - - {entity} + {loading &&

Loading...

} + +
+ {items.map((item) => ( + +
+
+

{item.message}

+

+ {item.author} · {dayjs(item.date).format('MMM D, YYYY HH:mm')} +

+
+ + {item.actionType} - ))} -
- {item.actionType === 'UPDATE' && ( - - )} -
- ))} +
+
+ {item.affectedEntities.map((entity) => ( + + {entity} + + ))} +
+ {item.actionType === 'UPDATE' && ( + + )} + + ))} +
) diff --git a/ui/app/datastreams/page.tsx b/ui/app/datastreams/page.tsx index ea5d713b..d013d78a 100644 --- a/ui/app/datastreams/page.tsx +++ b/ui/app/datastreams/page.tsx @@ -51,44 +51,63 @@ export default function DatastreamsPage() { const items = data?.value || [] return ( -
-

Datastreams

- +
+
+
+

Datastreams

+

Inspect stream definitions and temporal validity with clear context.

+
- - GET {activeUrl} - + +
+ +
+ GET {activeUrl} +
+
+
- {mode !== 'current' && } - {mode !== 'current' && items.length === 0 && ( - - )} + {mode !== 'current' && } + {mode !== 'current' && items.length === 0 && ( + + )} - {loading &&

Loading...

} - {error &&

{error.message}

} + {loading &&

Loading...

} + {error &&

{error.message}

} - - - ID - Name - Description - Observation Type - Phenomenon Time - System validity - - - {(item) => ( - - {item['@iot.id']} - {item.name} - {item.description} - {item.observationType} - {item.phenomenonTime || '-'} - {item.systemTimeValidity || '-'} - - )} - -
+ + + + ID + Name + Description + Observation Type + Phenomenon Time + System validity + + + {(item) => ( + + {item['@iot.id']} + {item.name} + {item.description} + {item.observationType} + {item.phenomenonTime || '-'} + {item.systemTimeValidity || '-'} + + )} + +
+
+
) } diff --git a/ui/app/history/page.tsx b/ui/app/history/page.tsx new file mode 100644 index 00000000..c9caf7ce --- /dev/null +++ b/ui/app/history/page.tsx @@ -0,0 +1,119 @@ +'use client' + +// Copyright 2026 SUPSI +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { Card } from '@heroui/card' +import { Tab, Tabs } from '@heroui/tabs' +import * as React from 'react' + +import ActivityGraph from '@/features/history/components/ActivityGraph' +import HistoryDetails from '@/features/history/components/HistoryDetails' +import { + ActivityBucket, + HistoryEntityType, + HistoryResponse, +} from '@/features/history/types' + +import TemporalModeSwitch from '@/components/TemporalModeSwitch' + +import { useTemporal } from '@/context/TemporalContext' + +import { appendTemporalParams } from '@/server/temporal' + +const ENTITY_TYPES: HistoryEntityType[] = [ + 'Thing', + 'Sensor', + 'Datastream', + 'Location', + 'ObservedProperty', + 'FeatureOfInterest', +] + +export default function HistoryPage() { + const { mode, asOf, fromTo } = useTemporal() + const [entityType, setEntityType] = React.useState('Thing') + const [loading, setLoading] = React.useState(true) + const [data, setData] = React.useState(null) + const [activity, setActivity] = React.useState([]) + + const historyUrl = React.useMemo(() => { + const baseUrl = `/api/history?entityType=${entityType}` + return appendTemporalParams(baseUrl, { mode, asOf, fromTo }) + }, [entityType, mode, asOf, fromTo]) + + React.useEffect(() => { + setLoading(true) + + Promise.all([ + fetch(historyUrl).then((response) => response.json()), + fetch(`/api/history/activity?entityType=${entityType}&period=week`).then( + (response) => response.json() + ), + ]) + .then(([historyPayload, activityPayload]) => { + setData(historyPayload) + setActivity(activityPayload?.value || []) + }) + .finally(() => setLoading(false)) + }, [historyUrl, entityType]) + + return ( +
+
+
+

History Explorer

+

+ Explore temporal snapshots and commit evolution with a clear, time-aware view. +

+
+ + +
+ + +
+ + setEntityType(String(key) as HistoryEntityType) + } + variant="solid" + color="primary" + classNames={{ + tabList: 'bg-[var(--color-surface-elevated)] border border-[var(--color-border)]', + tab: 'text-[var(--color-text-secondary)] data-[selected=true]:text-white', + cursor: 'bg-[var(--color-accent)]', + }} + > + {ENTITY_TYPES.map((type) => ( + + ))} + + +
+

History query

+

+ GET {historyUrl} +

+
+
+
+
+ + + +
+
+ ) +} diff --git a/ui/app/layout.tsx b/ui/app/layout.tsx index c4ff1c3c..71a589f4 100644 --- a/ui/app/layout.tsx +++ b/ui/app/layout.tsx @@ -27,7 +27,10 @@ export default function RootLayout({ children }: { children: ReactNode }) { return ( diff --git a/ui/app/things/page.tsx b/ui/app/things/page.tsx index 2ed4ba69..41931087 100644 --- a/ui/app/things/page.tsx +++ b/ui/app/things/page.tsx @@ -48,39 +48,58 @@ export default function ThingsPage() { const items = data?.value || [] return ( -
-

Things

- +
+
+
+

Things

+

Review entity metadata across live and historical states.

+
- - GET {activeUrl} - + +
+ +
+ GET {activeUrl} +
+
+
- {mode !== 'current' && items.length === 0 && ( - - )} + {mode !== 'current' && items.length === 0 && ( + + )} - {loading &&

Loading...

} - {error &&

{error.message}

} + {loading &&

Loading...

} + {error &&

{error.message}

} - - - ID - Name - Description - System validity - - - {(item) => ( - - {item['@iot.id']} - {item.name} - {item.description} - {item.systemTimeValidity || '-'} - - )} - -
+ + + + ID + Name + Description + System validity + + + {(item) => ( + + {item['@iot.id']} + {item.name} + {item.description} + {item.systemTimeValidity || '-'} + + )} + +
+
+
) } diff --git a/ui/components/PhantomEditWarning.tsx b/ui/components/PhantomEditWarning.tsx index e894336b..df3167bb 100644 --- a/ui/components/PhantomEditWarning.tsx +++ b/ui/components/PhantomEditWarning.tsx @@ -19,7 +19,7 @@ import { Card } from '@heroui/card' export default function PhantomEditWarning() { return ( - + Note: Changes to phenomenonTime and observedArea are not versioned. The history below reflects only metadata and configuration changes. ) diff --git a/ui/components/TemporalConflictWarning.tsx b/ui/components/TemporalConflictWarning.tsx index 988c29a0..42f7a95d 100644 --- a/ui/components/TemporalConflictWarning.tsx +++ b/ui/components/TemporalConflictWarning.tsx @@ -24,7 +24,7 @@ type Props = { export default function TemporalConflictWarning({ asOf }: Props) { return ( - + This entity did not exist at {asOf ? dayjs(asOf).format('MMM D, YYYY, HH:mm') : 'the selected time'}. Try a more recent timestamp. ) diff --git a/ui/components/TemporalModeSwitch.tsx b/ui/components/TemporalModeSwitch.tsx index 81bc7d1f..3d03efe9 100644 --- a/ui/components/TemporalModeSwitch.tsx +++ b/ui/components/TemporalModeSwitch.tsx @@ -37,13 +37,18 @@ export default function TemporalModeSwitch() { const { mode, asOf, fromTo, setMode, setAsOf, setFromTo } = useTemporal() return ( -
+
setMode(String(key) as 'current' | 'as_of' | 'from_to')} aria-label="Temporal mode switch" variant="solid" color="primary" + classNames={{ + tabList: 'bg-[var(--color-surface-elevated)] border border-[var(--color-border)]', + tab: 'text-[var(--color-text-secondary)] data-[selected=true]:text-white', + cursor: 'bg-[var(--color-accent)]', + }} > @@ -59,12 +64,18 @@ export default function TemporalModeSwitch() { onChange={(event) => setAsOf(fromLocalInputValue(event.target.value))} size="sm" radius="sm" + classNames={{ + label: 'text-[var(--color-text-secondary)]', + inputWrapper: + 'bg-[var(--color-surface-elevated)] border border-[var(--color-border)] text-[var(--color-text-primary)]', + input: 'text-[var(--color-text-primary)]', + }} />
)} {mode === 'from_to' && ( -
+
)} diff --git a/ui/components/icons.tsx b/ui/components/icons.tsx index 0356f4ca..e781304a 100644 --- a/ui/components/icons.tsx +++ b/ui/components/icons.tsx @@ -13,6 +13,23 @@ // limitations under the License. import { IconSvgProps, LogoProps } from '@/types' +function normalizeBasePath(rawBasePath?: string) { + if (!rawBasePath) return '' + if (rawBasePath === 'undefined' || rawBasePath === 'null') return '' + + const trimmed = rawBasePath.trim() + if (!trimmed || trimmed === '/' || trimmed === 'undefined' || trimmed === 'null') { + return '' + } + + const withoutTrailing = trimmed.replace(/\/+$/, '') + return withoutTrailing.startsWith('/') ? withoutTrailing : `/${withoutTrailing}` +} + +const basePath = normalizeBasePath(process.env.NEXT_PUBLIC_BASE_PATH) +const logoIstSOSSrc = `${basePath}/istsos_logo.png` +const logoOSGeoSrc = `${basePath}/osgeo_logo.png` + export const LogoIstSOS = ({ size = 24, width, @@ -24,11 +41,7 @@ export const LogoIstSOS = ({ return ( Logo IstSOS l.code === selectedLang)?.flag ?? languages[0].flag + const prototypePages = [ + { label: 'Things', href: '/things' }, + { label: 'Datastreams', href: '/datastreams' }, + { label: 'Commits', href: '/commits' }, + { label: 'History', href: '/history' }, + ] + return ( -
-
+
+
-
+
+
+ +
+ +
+ {prototypePages.map((page) => ( + + ))} +
+ + +
+
+ ) : null} +
{wizardMode === 'single' ? (