mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
improvements and bug fixes
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -78,8 +78,8 @@
|
||||
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
||||
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
||||
<title>Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-BmIqHXVL.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DtN3aedy.css">
|
||||
<script type="module" crossorigin src="/assets/index-B5yBezZ5.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-JqEX_oga.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -6,7 +6,7 @@ const io = require('../../globals/io');
|
||||
const { getRole, roleEvents } = require('../roleService');
|
||||
const { getSocketIp } = require('../../helpers/ipResolver');
|
||||
|
||||
const ADMIN_ROLES = new Set(['admin', 'lockdown', 'lockdown-admin']);
|
||||
const ADMIN_ROLES = new Set(['admin', 'lockdown']);
|
||||
const MAX_HISTORY = 200;
|
||||
const history = [];
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ function formatWebhookUsername(payload) {
|
||||
const name = payload.nickname || payload.socketId?.slice(0, 6) || 'unknown';
|
||||
const botTag = payload.bot ? ' [BOT]' : '';
|
||||
const spectatorTag = payload.role === 'spectator' && !payload.bot ? ' [SPECTATOR]' : '';
|
||||
const adminTag = payload.role === 'admin' || payload.role === 'lockdown' || payload.role === 'lockdown-admin' ? ' [Rover Admin]' : '';
|
||||
const adminTag = payload.role === 'admin' || payload.role === 'lockdown' ? ' [Rover Admin]' : '';
|
||||
if (payload.fromDiscord) {
|
||||
const origin = payload.discordGuildName ? ` (From: ${payload.discordGuildName})` : '';
|
||||
return `${name}${origin}${botTag}${spectatorTag}${adminTag}`;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Identity Admin Service
|
||||
// Purpose: Exposes lockdown-admin socket operations for inspecting and editing the central identity database.
|
||||
// Purpose: Exposes lockdown admin socket operations for inspecting and editing the central identity database.
|
||||
// Scope: Owns transport-level authorization and delegates every database mutation to identityService.
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('identityAdminService');
|
||||
@@ -19,7 +19,7 @@ const MAX_FEATURE_STATE_JSON_BYTES = 64 * 1024;
|
||||
|
||||
function isLockdownAdminSocket(socket) {
|
||||
const role = getRole(socket);
|
||||
return role === 'lockdown' || role === 'lockdown-admin';
|
||||
return role === 'lockdown';
|
||||
}
|
||||
|
||||
function requireLockdownAdmin(socket) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// Purpose: Provides pure helpers for admin state projection, role checks, and structured error normalization.
|
||||
// Scope: Keeps runtime behavior unchanged by extracting deterministic helper logic from index orchestration.
|
||||
function isAdminRole(role) {
|
||||
return role === 'admin' || role === 'lockdown' || role === 'lockdown-admin';
|
||||
return role === 'admin' || role === 'lockdown';
|
||||
}
|
||||
|
||||
function buildAdminState(status, runHistory) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
function isAdminRole(role) {
|
||||
return role === 'admin' || role === 'lockdown' || role === 'lockdown-admin';
|
||||
return role === 'admin' || role === 'lockdown';
|
||||
}
|
||||
|
||||
function buildAdminState(status, runHistory) {
|
||||
|
||||
@@ -39,7 +39,6 @@ function createSidebarRenderer({ execFileAsync, ensureDir }) {
|
||||
switch (String(role)) {
|
||||
case 'admin':
|
||||
case 'lockdown':
|
||||
case 'lockdown-admin':
|
||||
return '#FCD34D';
|
||||
case 'spectator':
|
||||
return '#94A3B8';
|
||||
|
||||
@@ -8,7 +8,7 @@ const { isAdmin, isLockdownAdmin, getRole } = require('../roleService');
|
||||
|
||||
const SUBSCRIBE_LIMIT = 50;
|
||||
const SUBSCRIBE_WINDOW_MS = 10000;
|
||||
const STREAM_INTERVAL_MS = 1000;
|
||||
const STREAM_INTERVAL_MS = 2000;
|
||||
|
||||
function passesMode(socket) {
|
||||
const mode = getMode();
|
||||
|
||||
@@ -43,7 +43,7 @@ function normalizeKnownIps(raw = []) {
|
||||
}
|
||||
|
||||
function isAdminRole(role) {
|
||||
return role === 'admin' || role === 'lockdown' || role === 'lockdown-admin';
|
||||
return role === 'admin' || role === 'lockdown';
|
||||
}
|
||||
|
||||
function parseDeterrenceSelector(selector) {
|
||||
|
||||
@@ -42,7 +42,7 @@ function emitChange(reason, payload = {}) {
|
||||
}
|
||||
|
||||
function isAdminRole(role) {
|
||||
return role === 'admin' || role === 'lockdown' || role === 'lockdown-admin';
|
||||
return role === 'admin' || role === 'lockdown';
|
||||
}
|
||||
|
||||
function refreshSocketIdentityFlags(socket) {
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
1. fix rover request spam queue cheat
|
||||
2. remove dockIR stuff from webui
|
||||
3. maybe add better camera control? a complete rework or maybe just a precision mode
|
||||
4. improve spectator page, options on what to see and what not to see
|
||||
5. make it so the room camera panel stops when its out of view for bandwidth
|
||||
6. clean up lockdown-admin or statements everywhere
|
||||
7. fix this:
|
||||
1. maybe add better camera control? a complete rework or maybe just a precision mode
|
||||
2. improve spectator page, options on what to see and what not to see
|
||||
3. fix this:
|
||||
`Jun 18 15:14:18 roombaserver.local node[216731]: /home/daniel/MultiRoombaRover/server/src/services/roverManager/socketHandlers.js:92
|
||||
Jun 18 15:14:18 roombaserver.local node[216731]: cb({ error: err.message });
|
||||
Jun 18 15:14:18 roombaserver.local node[216731]: ^
|
||||
|
||||
@@ -61,9 +61,8 @@ export default function AdminPanelContent() {
|
||||
|
||||
const isAdmin =
|
||||
session?.role === 'admin' ||
|
||||
session?.role === 'lockdown' ||
|
||||
session?.role === 'lockdown-admin';
|
||||
const isLockdownAdmin = session?.role === 'lockdown' || session?.role === 'lockdown-admin';
|
||||
session?.role === 'lockdown';
|
||||
const isLockdownAdmin = session?.role === 'lockdown';
|
||||
|
||||
const currentMode = session?.mode ?? 'open';
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ function roleColors(role) {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
case 'lockdown':
|
||||
case 'lockdown-admin':
|
||||
return 'text-amber-300';
|
||||
case 'spectator':
|
||||
return 'text-slate-400';
|
||||
@@ -151,7 +150,7 @@ function chatRowClass(message, variant = 'message') {
|
||||
return 'flex flex-col gap-0.5 rounded-md border border-emerald-500/40 bg-emerald-950 px-0.5 py-0.5 text-sm text-neutral-100';
|
||||
}
|
||||
const isAdmin =
|
||||
message.role === 'admin' || message.role === 'lockdown' || message.role === 'lockdown-admin';
|
||||
message.role === 'admin' || message.role === 'lockdown';
|
||||
return `flex flex-col gap-0.5 rounded-md bg-neutral-800 px-0.5 py-0.5 text-sm text-neutral-100 ${
|
||||
isAdmin
|
||||
? 'border border-amber-400/30'
|
||||
|
||||
@@ -8,8 +8,8 @@ import { useSharedClock } from '../../hooks/useSharedClock.js';
|
||||
import SocialButton from '../SocialButton/index.jsx';
|
||||
import ChatPanel from '../ChatPanel/index.jsx';
|
||||
|
||||
const PRIVILEGED_ROLES = new Set(['admin', 'lockdown', 'lockdown-admin']);
|
||||
const LOCKDOWN_ROLES = new Set(['lockdown', 'lockdown-admin']);
|
||||
const PRIVILEGED_ROLES = new Set(['admin', 'lockdown']);
|
||||
const LOCKDOWN_ROLES = new Set(['lockdown']);
|
||||
const RESTRICTED_MODES = new Set(['admin', 'lockdown']);
|
||||
|
||||
function getModeDetails(mode = 'admin') {
|
||||
|
||||
@@ -11,7 +11,6 @@ function roleColors(role) {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
case 'lockdown':
|
||||
case 'lockdown-admin':
|
||||
return 'text-amber-300';
|
||||
case 'spectator':
|
||||
return 'text-slate-400';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Room Camera Panel
|
||||
// Purpose: Defines the Room Camera Panel module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { useRoomCameraSnapshots } from '../../hooks/useRoomCameraSnapshots.js';
|
||||
@@ -18,6 +18,7 @@ function EmptyState() {
|
||||
}
|
||||
|
||||
const ORIENTATIONS = ['horizontal', 'vertical'];
|
||||
const CAMERA_VISIBILITY_ROOT_MARGIN = '0px';
|
||||
|
||||
function normalizeOrientation(value, fallback) {
|
||||
if (ORIENTATIONS.includes(value)) {
|
||||
@@ -26,6 +27,82 @@ function normalizeOrientation(value, fallback) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function useCameraPanelSubscriptionGate() {
|
||||
const panelRef = useRef(null);
|
||||
const [isPanelVisible, setIsPanelVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
/*
|
||||
Browser visibility can only be measured after React has mounted a real DOM
|
||||
node. Starting closed avoids a short subscribe/unsubscribe burst for room
|
||||
camera panels that mount below the fold.
|
||||
*/
|
||||
const panel = panelRef.current;
|
||||
if (!panel || typeof window === 'undefined' || typeof document === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let intersectsViewport = true;
|
||||
|
||||
const publishVisibility = () => {
|
||||
/*
|
||||
Server upload bandwidth is only saved when the socket subscription is
|
||||
actually disabled. Combining viewport intersection with page visibility
|
||||
means a scrolled-away panel and a hidden browser tab both stop receiving
|
||||
room-camera frame uploads from socketGateway.js.
|
||||
*/
|
||||
setIsPanelVisible(intersectsViewport && document.visibilityState !== 'hidden');
|
||||
};
|
||||
|
||||
const handlePageVisibilityChange = () => {
|
||||
/*
|
||||
IntersectionObserver is about layout visibility, while the Page
|
||||
Visibility API is about whether the tab itself can be seen. The latter
|
||||
matters here because a background tab can still keep a mounted panel and
|
||||
socket alive unless we explicitly close the subscription gate.
|
||||
*/
|
||||
publishVisibility();
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handlePageVisibilityChange);
|
||||
|
||||
if (typeof IntersectionObserver !== 'function') {
|
||||
/*
|
||||
Without IntersectionObserver we cannot cheaply know whether the panel is
|
||||
clipped by a scroll container. Fall back to page visibility so browsers
|
||||
without the observer still behave correctly, just without scroll-based
|
||||
bandwidth savings.
|
||||
*/
|
||||
publishVisibility();
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handlePageVisibilityChange);
|
||||
};
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
/*
|
||||
A zero root margin keeps the bandwidth gate strict: the browser only
|
||||
subscribes once the panel intersects the viewport. If the first frame
|
||||
feels too delayed in real use, this constant can be widened later.
|
||||
*/
|
||||
intersectsViewport = Boolean(entry?.isIntersecting);
|
||||
publishVisibility();
|
||||
},
|
||||
{ root: null, rootMargin: CAMERA_VISIBILITY_ROOT_MARGIN, threshold: 0 },
|
||||
);
|
||||
|
||||
observer.observe(panel);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
document.removeEventListener('visibilitychange', handlePageVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { panelRef, isPanelVisible };
|
||||
}
|
||||
|
||||
export default function RoomCameraPanel({
|
||||
defaultOrientation = 'horizontal',
|
||||
orientation: forcedOrientation,
|
||||
@@ -34,7 +111,12 @@ export default function RoomCameraPanel({
|
||||
panelId = null,
|
||||
}) {
|
||||
const cameras = useSessionSelector((state) => state.session?.roomCameras || []);
|
||||
const feedMap = useRoomCameraSnapshots(cameras.map((camera) => ({ id: camera.id })));
|
||||
const cameraIds = useMemo(
|
||||
() => cameras.map((camera) => camera.id),
|
||||
[cameras],
|
||||
);
|
||||
const { panelRef, isPanelVisible } = useCameraPanelSubscriptionGate();
|
||||
const feedMap = useRoomCameraSnapshots(cameraIds, { enabled: isPanelVisible });
|
||||
const { value: orientationSettings, save: saveOrientationSettings } = useSettingsNamespace('roomCameraPanels', {});
|
||||
const [orientation, setOrientation] = useState(() =>
|
||||
normalizeOrientation(
|
||||
@@ -42,17 +124,19 @@ export default function RoomCameraPanel({
|
||||
'horizontal',
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!panelId) return;
|
||||
const stored = orientationSettings?.[panelId];
|
||||
if (!stored) return;
|
||||
setOrientation(normalizeOrientation(stored, 'horizontal'));
|
||||
// only respond to changes for this panel id
|
||||
}, [panelId, orientationSettings?.[panelId]]);
|
||||
const storedOrientation = panelId ? orientationSettings?.[panelId] : null;
|
||||
const effectiveOrientation = forcedOrientation
|
||||
? normalizeOrientation(forcedOrientation, 'horizontal')
|
||||
: orientation;
|
||||
: normalizeOrientation(
|
||||
/*
|
||||
Settings are external state, so derive from them during render instead
|
||||
of mirroring them into local state from an effect. Local state remains
|
||||
useful as the immediate value after clicking the layout toggle, while
|
||||
stored settings win once the settings provider has loaded or saved.
|
||||
*/
|
||||
storedOrientation || orientation,
|
||||
'horizontal',
|
||||
);
|
||||
const containerClass =
|
||||
effectiveOrientation === 'vertical' ? 'flex flex-col gap-0.5' : 'grid gap-0.5 md:grid-cols-2';
|
||||
const showLayoutToggle = !hideLayoutToggle && !forcedOrientation && cameras.length > 0;
|
||||
@@ -64,7 +148,11 @@ export default function RoomCameraPanel({
|
||||
};
|
||||
|
||||
if (cameras.length === 0) {
|
||||
return <EmptyState />;
|
||||
return (
|
||||
<div ref={panelRef}>
|
||||
<EmptyState />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const actions = showLayoutToggle ? (
|
||||
@@ -86,9 +174,9 @@ export default function RoomCameraPanel({
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div ref={panelRef}>
|
||||
<CardFrame
|
||||
title="Room cameras"
|
||||
|
||||
actions={actions}
|
||||
hideHeader={hideHeader}
|
||||
bodyClassName="space-y-0.5 text-base"
|
||||
@@ -108,5 +196,6 @@ export default function RoomCameraPanel({
|
||||
})}
|
||||
</div>
|
||||
</CardFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ function roleColors(role) {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
case 'lockdown':
|
||||
case 'lockdown-admin':
|
||||
return 'text-amber-300';
|
||||
case 'spectator':
|
||||
return 'text-slate-400';
|
||||
@@ -66,7 +65,7 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
|
||||
|
||||
const canRequest = useMemo(() => role && role !== 'spectator', [role]);
|
||||
const adminCapable = useMemo(
|
||||
() => role === 'admin' || role === 'lockdown' || role === 'lockdown-admin',
|
||||
() => role === 'admin' || role === 'lockdown',
|
||||
[role],
|
||||
);
|
||||
const hasDeadlines = useMemo(
|
||||
|
||||
@@ -4,14 +4,12 @@
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useVisualTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
||||
import { selectFrameForDisplay } from '../../context/telemetryViews.js';
|
||||
import { useDockIr } from '../../hooks/useDockIr.js';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
|
||||
export default function TelemetryPanel() {
|
||||
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||
const frame = useVisualTelemetrySelector(roverId, selectFrameForDisplay);
|
||||
const sensors = frame?.sensors || {};
|
||||
const dockIr = useDockIr(sensors);
|
||||
const voltage = sensors.voltageMv != null ? `${(sensors.voltageMv / 1000).toFixed(2)} V` : null;
|
||||
const current = sensors.currentMa != null ? `${sensors.currentMa} mA` : null;
|
||||
const batteryTemp = sensors.batteryTemperatureC != null ? `${sensors.batteryTemperatureC} °C` : null;
|
||||
@@ -35,7 +33,7 @@ export default function TelemetryPanel() {
|
||||
) : (
|
||||
<>
|
||||
<TelemetrySummary sensors={sensors} voltage={voltage} current={current} batteryTemp={batteryTemp} charge={charge} capacity={capacity} />
|
||||
<SensorDetails sensors={sensors} dockState={dockIr} />
|
||||
<SensorDetails sensors={sensors} />
|
||||
</>
|
||||
)}
|
||||
{rawSnippet && (
|
||||
@@ -75,7 +73,7 @@ function Metric({ label, value }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SensorDetails({ sensors, dockState }) {
|
||||
function SensorDetails({ sensors }) {
|
||||
const bumps = sensors?.bumpsAndWheelDrops || {};
|
||||
const over = sensors?.wheelOvercurrents || {};
|
||||
|
||||
@@ -104,11 +102,6 @@ function SensorDetails({ sensors, dockState }) {
|
||||
|
||||
return (
|
||||
<div className="grid gap-0.5 md:grid-cols-2">
|
||||
<DetailCard title="Dock IR">
|
||||
<DockMiniStatus sensors={sensors} />
|
||||
<DockDebug state={dockState} />
|
||||
</DetailCard>
|
||||
|
||||
<DetailCard title="Bumps & drops">
|
||||
<ValueRow label="Bump L" value={<Pill active={bumps.bumpLeft} />} />
|
||||
<ValueRow label="Bump R" value={<Pill active={bumps.bumpRight} />} />
|
||||
@@ -190,49 +183,3 @@ function formatNumber(value, unit) {
|
||||
if (value == null || Number.isNaN(value)) return '--';
|
||||
return unit ? `${value} ${unit}` : value;
|
||||
}
|
||||
|
||||
function DockMiniStatus({ sensors }) {
|
||||
const left = sensors?.infraredCharacterLeft;
|
||||
const right = sensors?.infraredCharacterRight;
|
||||
const omni = sensors?.infraredCharacterOmni;
|
||||
const badge = (code) => (
|
||||
<span className={`rounded px-1 py-0.25 text-[0.7rem] font-semibold ${code ? 'bg-emerald-600 text-white' : 'bg-slate-700 text-slate-300'}`}>
|
||||
{code || '--'}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-0.5 text-xs text-slate-200">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<span className="text-slate-400">L</span>
|
||||
{badge(left)}
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<span className="text-slate-400">O</span>
|
||||
{badge(omni)}
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<span className="text-slate-400">R</span>
|
||||
{badge(right)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DockDebug({ state }) {
|
||||
if (!state) return null;
|
||||
const label = (entry) => {
|
||||
const parts = [];
|
||||
if (entry?.red) parts.push('R');
|
||||
if (entry?.green) parts.push('G');
|
||||
if (entry?.force) parts.push('F');
|
||||
return parts.length ? parts.join('') : 'none';
|
||||
};
|
||||
const age = (entry) => (entry?.age != null ? `${entry.age}ms` : '--');
|
||||
return (
|
||||
<div className="text-[0.7rem] text-slate-400">
|
||||
<div>{`Left: ${state.left.code ?? '--'} (${label(state.left)}) · age ${age(state.left)}`}</div>
|
||||
<div>{`Omni: ${state.omni.code ?? '--'} (${label(state.omni)}) · age ${age(state.omni)}`}</div>
|
||||
<div>{`Right: ${state.right.code ?? '--'} (${label(state.right)}) · age ${age(state.right)}`}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ function roleColors(role) {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
case 'lockdown':
|
||||
case 'lockdown-admin':
|
||||
return 'text-amber-300';
|
||||
case 'spectator':
|
||||
return 'text-slate-400';
|
||||
@@ -120,7 +119,7 @@ export default function UserListPanel({
|
||||
) : (
|
||||
sorted.map((user) => {
|
||||
const isAdmin =
|
||||
user.role === 'admin' || user.role === 'lockdown' || user.role === 'lockdown-admin';
|
||||
user.role === 'admin' || user.role === 'lockdown';
|
||||
return (
|
||||
<div
|
||||
key={user.socketId}
|
||||
@@ -229,7 +228,7 @@ export default function UserListPanel({
|
||||
const isNext = Boolean(nextId && socketId === nextId && !isCurrent);
|
||||
const isSelf = Boolean(selfId && socketId === selfId);
|
||||
const isAdmin =
|
||||
user.role === 'admin' || user.role === 'lockdown' || user.role === 'lockdown-admin';
|
||||
user.role === 'admin' || user.role === 'lockdown';
|
||||
const highlightClass = isCurrent
|
||||
? 'bg-sky-600 text-white ring-2 ring-amber-300 animate-pulse'
|
||||
: isNext
|
||||
|
||||
@@ -130,7 +130,7 @@ export function useOvercurrentLimiter(roverId, options = {}) {
|
||||
return { motors, groups };
|
||||
}, [overcurrentFlags]);
|
||||
|
||||
const adminImmune = role === 'admin' || role === 'lockdown' || role === 'lockdown-admin';
|
||||
const adminImmune = role === 'admin' || role === 'lockdown';
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Database Admin App
|
||||
// Purpose: Provides the dedicated /database route for lockdown-admin identity database management.
|
||||
// Purpose: Provides the dedicated /database route for lockdown admin identity database management.
|
||||
// Scope: Handles route-level identity sync, access gating, and composition of the self-contained database panel.
|
||||
import AuthPanel from '../components/AuthPanel/index.jsx';
|
||||
import CardFrame from '../components/CardFrame/index.jsx';
|
||||
@@ -10,7 +10,7 @@ import { pageBackgroundClass } from '../themeFlags.js';
|
||||
import IdentityDatabasePanel from './IdentityDatabasePanel.jsx';
|
||||
|
||||
function isLockdownAdminRole(role) {
|
||||
return role === 'lockdown' || role === 'lockdown-admin';
|
||||
return role === 'lockdown';
|
||||
}
|
||||
|
||||
export default function DatabaseAdminApp() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Identity Database Panel
|
||||
// Purpose: Implements the lockdown-admin identity database editor UI for the /database route.
|
||||
// Purpose: Implements the lockdown admin identity database editor UI for the /database route.
|
||||
// Scope: Keeps list, detail, signal, status, feature-state, and raw JSON editing local to this feature.
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import CardFrame from '../components/CardFrame/index.jsx';
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
// Hook: useDockIr
|
||||
// Purpose: Tracks dock IR telemetry/status values for docking-related UI indicators. Scope: Converts telemetry feed updates into component-friendly reactive state.
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
const HOLD_MS = 650;
|
||||
|
||||
const DOCK_CODES = {
|
||||
161: { red: true }, // Red buoy
|
||||
164: { green: true }, // Green buoy
|
||||
165: { force: true }, // Force field only
|
||||
168: { red: true, green: true }, // Red + green
|
||||
169: { red: true, force: true }, // Red + force
|
||||
172: { green: true, force: true }, // Green + force
|
||||
173: { red: true, green: true, force: true }, // Red + green + force
|
||||
};
|
||||
|
||||
function decodeDockBits(code) {
|
||||
if (typeof code !== 'number' || code <= 0) return null;
|
||||
const bits = DOCK_CODES[code];
|
||||
if (!bits) return null;
|
||||
return { code, ...bits };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a smoothed view of dock IR signals. Keeps the last seen code alive for HOLD_MS to mask blinks.
|
||||
*/
|
||||
export function useDockIr(sensors, options = {}) {
|
||||
const holdMs = options.holdMs || HOLD_MS;
|
||||
const [state, setState] = useState({
|
||||
left: null,
|
||||
right: null,
|
||||
omni: null,
|
||||
});
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick(Date.now()), Math.max(holdMs / 2, 100));
|
||||
return () => clearInterval(id);
|
||||
}, [holdMs]);
|
||||
|
||||
useEffect(() => {
|
||||
const now = Date.now();
|
||||
const left = decodeDockBits(sensors?.infraredCharacterLeft);
|
||||
const right = decodeDockBits(sensors?.infraredCharacterRight);
|
||||
const omni = decodeDockBits(sensors?.infraredCharacterOmni);
|
||||
if (!left && !right && !omni) {
|
||||
return;
|
||||
}
|
||||
setState((prev) => ({
|
||||
left: left ? { ...left, ts: now } : prev.left,
|
||||
right: right ? { ...right, ts: now } : prev.right,
|
||||
omni: omni ? { ...omni, ts: now } : prev.omni,
|
||||
}));
|
||||
}, [sensors?.infraredCharacterLeft, sensors?.infraredCharacterRight, sensors?.infraredCharacterOmni]);
|
||||
|
||||
return useMemo(() => {
|
||||
const now = Date.now();
|
||||
const withWindow = (entry) => {
|
||||
if (!entry) return { active: false, red: false, green: false, force: false, age: null, code: null };
|
||||
const age = now - entry.ts;
|
||||
const active = age <= holdMs;
|
||||
return {
|
||||
active,
|
||||
age,
|
||||
code: entry.code,
|
||||
red: Boolean(entry.red && active),
|
||||
green: Boolean(entry.green && active),
|
||||
force: Boolean(entry.force && active),
|
||||
};
|
||||
};
|
||||
|
||||
const left = withWindow(state.left);
|
||||
const right = withWindow(state.right);
|
||||
const omni = withWindow(state.omni);
|
||||
const forceDetected = left.force || right.force || omni.force;
|
||||
const visible = left.active || right.active || omni.active;
|
||||
const bias =
|
||||
left.active && !right.active
|
||||
? 'right'
|
||||
: right.active && !left.active
|
||||
? 'left'
|
||||
: 'center';
|
||||
const balance = (right.active ? 1 : 0) - (left.active ? 1 : 0); // positive means steer left-to-right
|
||||
|
||||
return {
|
||||
visible,
|
||||
forceDetected,
|
||||
left,
|
||||
right,
|
||||
omni,
|
||||
bias, // left/right/center for steering cue
|
||||
balance: Math.max(-1, Math.min(1, balance)),
|
||||
};
|
||||
}, [state.left, state.right, state.omni, holdMs, tick]);
|
||||
}
|
||||
Reference in New Issue
Block a user