Enhance Dashboard Navigation and Live Activities Integration
Some checks failed
farmcontrol/farmcontrol-ui/pipeline/head There was a failure building this commit

- Updated vite.config.js to optimize React chunking for antd imports, preventing issues with hooks.
- Added new LiveActivities component to manage and display real-time activity notifications in the dashboard.
- Refactored DashboardNavigation component to include LiveActivities, improving user interaction with activity notifications.
- Introduced new CSS styles for dashboard navigation activities, enhancing visual feedback and responsiveness.
This commit is contained in:
Tom Butcher 2026-11-08 19:14:42 +00:00
parent b3e366e813
commit 2e1375ce2d
4 changed files with 636 additions and 20 deletions

View File

@ -4208,6 +4208,142 @@ body.objectKanbanColumnResizing * {
stroke-width: 12px; stroke-width: 12px;
} }
.dashboard-navigation-activity-cluster {
gap: 0;
transition: gap 0.3s cubic-bezier(0.2, 0, 0, 1);
}
.dashboard-navigation-activity-cluster:has(.live-activities-open) {
gap: var(--dashboard-navigation-gap, 8px);
}
.live-activities {
overflow: hidden;
border-radius: 12px;
flex: 0 0 auto;
transition:
width 0.3s cubic-bezier(0.2, 0, 0, 1),
opacity 0.3s cubic-bezier(0.2, 0, 0, 1);
}
.live-activities-row {
display: flex;
align-items: stretch;
height: 100%;
width: max-content;
}
.live-activity-slot {
overflow: hidden;
flex: 0 0 auto;
height: 100%;
position: relative;
transition: width 0.3s cubic-bezier(0.2, 0, 0, 1);
}
.live-activity {
position: absolute;
top: 0;
left: 0;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
padding: 0;
border: 0;
border-radius: 0;
overflow: hidden;
cursor: pointer;
line-height: 1;
container-type: inline-size;
background: color-mix(in srgb, var(--live-activity-color) 20%, transparent);
transition: background 0.2s ease;
}
.live-activity:hover {
background: color-mix(in srgb, var(--live-activity-color) 35%, transparent);
}
.live-activity-progress,
.live-activity-flash {
position: absolute;
top: 0;
bottom: 0;
left: 0;
pointer-events: none;
}
.live-activity-flash {
right: 0;
}
.live-activity-progress {
overflow: hidden;
}
.live-activity-progress-fill {
position: absolute;
inset: 0;
opacity: 0.22;
transition: opacity 0.2s ease;
}
.live-activity:hover .live-activity-progress-fill {
opacity: 0.4;
}
.live-activity-progress-wave {
position: absolute;
inset-block: 0;
inset-inline-start: -80cqw;
width: 240cqw;
background: repeating-linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0,
rgba(255, 255, 255, 0.05) 40cqw,
rgba(255, 255, 255, 0) 80cqw
);
animation: live-activity-progress-wave 1s linear infinite;
}
@keyframes live-activity-progress-wave {
0% {
transform: translateX(0);
}
100% {
transform: translateX(calc(100% / 3));
}
}
.live-activity-icon {
position: relative;
z-index: 1;
display: flex;
line-height: 1;
color: var(--color-text);
}
@keyframes live-activity-flash {
0%,
100% {
opacity: var(--live-activity-flash-from);
}
50% {
opacity: var(--live-activity-flash-to);
}
}
.live-activity-flash {
--live-activity-flash-from: 0;
--live-activity-flash-to: 0.2;
animation: live-activity-flash 2s ease-in-out infinite;
}
.live-activity:hover .live-activity-flash {
--live-activity-flash-from: 0.12;
--live-activity-flash-to: 0.45;
}
.printerQueue.ant-steps.ant-steps-vertical .printerQueue.ant-steps.ant-steps-vertical
> .ant-steps-item:last-child > .ant-steps-item:last-child
.ant-steps-item-content { .ant-steps-item-content {

View File

@ -17,7 +17,8 @@ import {
Badge, Badge,
Divider, Divider,
Typography, Typography,
Popover Popover,
theme
} from 'antd' } from 'antd'
import classNames from 'classnames' import classNames from 'classnames'
import { AuthContext } from '../context/AuthContext' import { AuthContext } from '../context/AuthContext'
@ -46,6 +47,7 @@ import SettingsIcon from '../../Icons/SettingsIcon'
import { ElectronContext } from '../context/ElectronContext' import { ElectronContext } from '../context/ElectronContext'
import DashboardWindowButtons from './DashboardWindowButtons' import DashboardWindowButtons from './DashboardWindowButtons'
import DashboardDevControls from './DashboardDevControls' import DashboardDevControls from './DashboardDevControls'
import LiveActivities from './LiveActivities'
import WindowAppMenu from './WindowAppMenu' import WindowAppMenu from './WindowAppMenu'
import WebAppSwitcher from './WebAppSwitcher' import WebAppSwitcher from './WebAppSwitcher'
import DashboardTabs from './DashboardTabs' import DashboardTabs from './DashboardTabs'
@ -148,6 +150,8 @@ const DashboardNavigationTrailingItems = memo(
onNotifications, onNotifications,
onCheckUpdates onCheckUpdates
}) { }) {
const { token } = theme.useToken()
return ( return (
<Flex gap='small' align='center' style={NAV_TRAILING_FLEX_STYLE}> <Flex gap='small' align='center' style={NAV_TRAILING_FLEX_STYLE}>
{controlsReady ? ( {controlsReady ? (
@ -165,25 +169,34 @@ const DashboardNavigationTrailingItems = memo(
onClick={onSearch} onClick={onSearch}
/> />
</KeyboardShortcut> </KeyboardShortcut>
<Badge <Flex
count={unreadCount} align='center'
size='small' className='dashboard-navigation-activity-cluster'
offset={[-5, 8]} style={{
style={NAV_BADGE_STYLE} '--dashboard-navigation-gap': `${token.paddingXS}px`
}}
> >
<KeyboardShortcut <LiveActivities />
shortcut='alt+n' <Badge
hint='ALT N' count={unreadCount}
onTrigger={onNotifications} size='small'
offset={[-5, 8]}
style={NAV_BADGE_STYLE}
> >
<Button <KeyboardShortcut
icon={<BellIcon />} shortcut='alt+n'
type='text' hint='ALT N'
style={NAV_BELL_BUTTON_STYLE} onTrigger={onNotifications}
onClick={onNotifications} >
/> <Button
</KeyboardShortcut> icon={<BellIcon />}
</Badge> type='text'
style={NAV_BELL_BUTTON_STYLE}
onClick={onNotifications}
/>
</KeyboardShortcut>
</Badge>
</Flex>
</Space> </Space>
) : null} ) : null}
<DashboardDevControls /> <DashboardDevControls />

View File

@ -0,0 +1,450 @@
import {
createElement,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState
} from 'react'
import { theme, Popover } from 'antd'
import { useNavigate } from 'react-router-dom'
import { ApiServerContext } from '../context/ApiServerContext'
import { AuthContext } from '../context/AuthContext'
import { getModelByName } from '../../../database/ObjectModels'
import { getStateTagInfo } from '../utils/Utils'
import SpotlightTooltip from './SpotlightTooltip'
const ACTIVITY_TYPES = new Set([
'printing',
'processing',
'deploying',
'loading',
'queued',
'syncing',
'publishing',
'unpublishing',
'initializing',
'rendering',
'sending',
'connecting',
'downloading',
'startup',
'paused',
'used'
])
const PROGRESS_TYPES = new Set([
'loading',
'processing',
'queued',
'printing',
'used',
'deploying',
'syncing',
'publishing',
'unpublishing'
])
const STATUS_COLOR_VAR = {
success: '--color-success',
warning: '--color-warning',
error: '--color-error',
processing: '--color-info',
info: '--color-info',
default: '--color-info',
cyan: '--color-cyan',
purple: '--color-purple',
magenta: '--color-magenta',
volcano: '--color-volcano',
pink: '--color-pink'
}
const LIVE_ACTIVITY_TRANSITION_MS = 300
const readState = (object) => {
const raw = object?.state ?? object?.status
if (raw && typeof raw === 'object') {
return {
type: raw.type,
progress: raw.progress,
message: raw.message
}
}
if (typeof raw === 'string') {
return { type: raw }
}
return null
}
const hasStateField = (object) => {
if (!object || typeof object !== 'object') return false
return object.state != null || object.status != null
}
const progressPercent = (state) => {
if (!state || !PROGRESS_TYPES.has(state.type)) return null
const progress = Number(state.progress)
if (!Number.isFinite(progress) || progress <= 0) return null
const percent =
progress <= 1
? Math.round(progress * 1000) / 10
: Math.round(progress * 10) / 10
if (percent <= 0 || percent >= 100) return null
return percent
}
const objectIdOf = (value) => {
if (!value) return null
if (typeof value === 'string') return value
return value._id ?? null
}
const mergeObjectPatch = (current, patch) => {
if (!patch || typeof patch !== 'object') return current
const next = { ...(current || {}), ...patch }
if (
patch.state &&
typeof patch.state === 'object' &&
current?.state &&
typeof current.state === 'object'
) {
next.state = { ...current.state, ...patch.state }
}
if (
patch.status &&
typeof patch.status === 'object' &&
current?.status &&
typeof current.status === 'object'
) {
next.status = { ...current.status, ...patch.status }
}
return next
}
const LiveActivities = () => {
const { token } = theme.useToken()
const navigate = useNavigate()
const {
userProfile,
token: authToken,
authInitialized
} = useContext(AuthContext)
const {
fetchObjects,
connected,
subscribeToObjectTypeUpdates,
subscribeToObjectUpdates
} = useContext(ApiServerContext)
const [watched, setWatched] = useState([])
const userId = userProfile?._id
const authReady = Boolean(authToken && authInitialized && userId)
const apiRef = useRef({ fetchObjects })
apiRef.current = { fetchObjects }
const watchedRef = useRef(watched)
watchedRef.current = watched
const loadNotifiers = useCallback(async () => {
if (!userId) {
setWatched([])
return
}
const result = await apiRef.current.fetchObjects('userNotifier', {
filter: { user: userId },
limit: 200
})
const notifiers = Array.isArray(result?.data)
? result.data
: Array.isArray(result)
? result
: []
setWatched(
notifiers
.map((notifier) => {
const objectId = objectIdOf(notifier?.object)
if (!objectId || !notifier?.objectType) return null
const object =
notifier.object && typeof notifier.object === 'object'
? notifier.object
: { _id: objectId }
return {
notifierId: notifier._id,
objectType: notifier.objectType,
object
}
})
.filter(Boolean)
)
}, [userId])
const loadNotifiersRef = useRef(loadNotifiers)
loadNotifiersRef.current = loadNotifiers
useEffect(() => {
if (!authReady || !connected) {
if (!authReady) setWatched([])
return undefined
}
loadNotifiersRef.current()
return undefined
}, [authReady, connected])
useEffect(() => {
if (!authReady || connected !== true || !userId) return undefined
const unsubscribe = subscribeToObjectTypeUpdates(
'userNotifier',
{ user: userId },
() => {
loadNotifiersRef.current()
}
)
return () => unsubscribe?.()
}, [authReady, connected, subscribeToObjectTypeUpdates, userId])
const subscriptionKey = useMemo(
() =>
watched
.filter((item) => hasStateField(item.object))
.map(
(item) =>
`${item.objectType}:${String(item.object._id).toLowerCase()}`
)
.sort()
.join('|'),
[watched]
)
useEffect(() => {
if (connected !== true || !subscriptionKey) return undefined
const unsubscribes = watchedRef.current
.filter((item) => hasStateField(item.object))
.map((item) => {
const id = String(item.object._id).toLowerCase()
return subscribeToObjectUpdates(id, item.objectType, (patch) => {
setWatched((prev) =>
prev.map((entry) => {
const entryId = entry.object?._id
if (!entryId || String(entryId).toLowerCase() !== id) return entry
if (entry.objectType !== item.objectType) return entry
return {
...entry,
object: mergeObjectPatch(entry.object, patch)
}
})
)
})
})
return () => {
unsubscribes.forEach((unsubscribe) => unsubscribe?.())
}
}, [connected, subscriptionKey, subscribeToObjectUpdates])
const activities = useMemo(
() =>
watched.flatMap((item) => {
const state = readState(item.object)
if (!state?.type || !ACTIVITY_TYPES.has(state.type)) return []
const model = getModelByName(item.objectType)
const { status, text } = getStateTagInfo(state.type)
const percent = progressPercent(state)
const name = item.object?.name || model.label
return [
{
key: `${item.objectType}:${item.object._id}`,
id: item.object._id,
name,
model,
status,
text,
percent
}
]
}),
[watched]
)
const [slots, setSlots] = useState([])
const activityKeysRef = useRef(new Set())
activityKeysRef.current = new Set(activities.map((activity) => activity.key))
useEffect(() => {
setSlots((prev) => {
const seen = new Set()
const next = []
for (const slot of prev) {
const fresh = activities.find((activity) => activity.key === slot.key)
if (fresh) {
next.push({ ...fresh, open: slot.open })
seen.add(slot.key)
} else {
next.push({ ...slot, open: false })
}
}
for (const activity of activities) {
if (seen.has(activity.key)) continue
next.push({ ...activity, open: false })
}
return next
})
}, [activities])
useEffect(() => {
const needsOpen = slots.some(
(slot) => !slot.open && activityKeysRef.current.has(slot.key)
)
if (!needsOpen) return undefined
const frame = window.requestAnimationFrame(() => {
setSlots((current) => {
const live = activityKeysRef.current
let changed = false
const next = current.map((slot) => {
if (!slot.open && live.has(slot.key)) {
changed = true
return { ...slot, open: true }
}
return slot
})
return changed ? next : current
})
})
return () => window.cancelAnimationFrame(frame)
}, [slots])
useEffect(() => {
const closing = slots.some(
(slot) => !slot.open && !activityKeysRef.current.has(slot.key)
)
if (!closing) return undefined
const timeoutId = window.setTimeout(() => {
setSlots((current) =>
current.filter(
(slot) => slot.open || activityKeysRef.current.has(slot.key)
)
)
}, LIVE_ACTIVITY_TRANSITION_MS + 40)
return () => window.clearTimeout(timeoutId)
}, [slots])
const size = token.controlHeight
const iconSize = Math.max(12, Math.round(size * 0.5))
const openCount = slots.filter((slot) => slot.open).length
const activitiesOpen = openCount > 0
return (
<div
className={`live-activities${activitiesOpen ? ' live-activities-open' : ''}`}
aria-hidden={activitiesOpen ? undefined : true}
style={{
width: openCount * size,
height: size,
marginTop: 2,
opacity: activitiesOpen ? 1 : 0,
pointerEvents: activitiesOpen ? undefined : 'none'
}}
>
<div className='live-activities-row'>
{slots.map((activity) => {
const colorVar = STATUS_COLOR_VAR[activity.status] || '--color-info'
const color = `var(${colorVar})`
const tooltip =
activity.percent == null
? `${activity.name} · ${activity.text}`
: `${activity.name} · ${activity.text} ${activity.percent}%`
const open = () => {
if (!activity.open || typeof activity.model.url !== 'string') return
navigate(
`${activity.model.url}/info?${activity.model.name}Id=${activity.id}`
)
}
return (
<Popover
key={activity.key}
content={
<SpotlightTooltip
query={`${activity.model.prefix}:${activity.id}`}
type={activity.model.name}
/>
}
trigger='hover'
placement='bottomRight'
arrow={false}
style={{ padding: 0 }}
>
<div
className='live-activity-slot'
style={{ width: activity.open ? size : 0 }}
onTransitionEnd={(event) => {
if (event.target !== event.currentTarget) return
if (event.propertyName !== 'width') return
if (activity.open) return
setSlots((current) =>
current.filter((slot) => slot.key !== activity.key)
)
}}
>
<button
type='button'
className='live-activity'
aria-label={tooltip}
tabIndex={activity.open ? 0 : -1}
onClick={open}
style={{
width: size,
height: size,
'--live-activity-color': color
}}
>
<span
className={
activity.percent == null
? 'live-activity-flash'
: 'live-activity-progress'
}
style={{
background: activity.percent == null ? color : undefined,
width:
activity.percent == null
? undefined
: `${activity.percent}%`
}}
>
{activity.percent == null ? null : (
<>
<span
className='live-activity-progress-fill'
style={{ background: color }}
/>
<span className='live-activity-progress-wave' />
</>
)}
</span>
<span className='live-activity-icon'>
{createElement(activity.model.icon, {
style: { fontSize: iconSize }
})}
</span>
</button>
</div>
</Popover>
)
})}
</div>
</div>
)
}
export default LiveActivities

View File

@ -50,12 +50,29 @@ export default defineConfig({
three: path.resolve(__dirname, 'node_modules/three'), three: path.resolve(__dirname, 'node_modules/three'),
...codemirrorSingletons ...codemirrorSingletons
}, },
dedupe: Object.keys(codemirrorSingletons) // Deep `antd/es/*` imports are optimized separately from the app entry.
// Without dedupe they get their own React chunk, and hooks throw
// "Cannot read properties of null (reading 'useContext')".
dedupe: ['react', 'react-dom', ...Object.keys(codemirrorSingletons)]
}, },
optimizeDeps: { optimizeDeps: {
// Do not prebundle CodeMirror — pnpm + esbuild duplicates @codemirror/state // Do not prebundle CodeMirror — pnpm + esbuild duplicates @codemirror/state
// and breaks FacetProvider instanceof checks. // and breaks FacetProvider instanceof checks.
include: ['@ant-design/icons'], // Include antd's internal entry points up front so they share the app's
// React chunk instead of being discovered later and bundled apart.
include: [
'react',
'react-dom',
'react-dom/client',
'react/jsx-dev-runtime',
'antd',
'antd/es/config-provider/hooks/useCSSVarCls',
'antd/es/config-provider/hooks/useSize',
'antd/es/input/style',
'antd/es/space/Compact',
'antd/es/layout/layout',
'@ant-design/icons'
],
exclude: [ exclude: [
'@uiw/react-codemirror', '@uiw/react-codemirror',
'codemirror', 'codemirror',