feat(housekeeping): in-client admin panel

Adds the Housekeeping in-client admin panel — a Modtools-adjacent
surface that runs entirely inside the React client, talking to the
emulator over the existing wire instead of a separate REST/CMS layer.

Surface:
- `src/components/housekeeping/` — panel shell + 5 tabs (Dashboard,
  Users, Rooms, Economy, Audit). Each tab drives one domain of the
  matching emulator handlers (find/sanction/admin/economy/catalog/
  hotel-wide).
- `src/api/housekeeping/` — composer/parser orchestration:
  `HousekeepingApi.ts` exposes 30+ typed actions, each one running
  through `runHkAction()` which awaits the shared
  `HousekeepingActionResultEvent` correlated by action key.
- `src/hooks/housekeeping/` — `useHousekeeping` (the public hook),
  `useHousekeepingStore` (useBetween singleton: shared selection +
  audit polling + sanction templates), `useHousekeepingActions`,
  `useHousekeepingConfirm`.
- `src/api/nitro/awaitMessageEvent.ts` — Promise adapter over
  `CommunicationManager.subscribeMessage` with a sync `select`
  callback that snapshots the parser INSIDE the subscribe handler
  before the renderer recycles the parser instance after the
  Promise resolves.
- `public/configuration/housekeeping-texts-{en,it}.example` —
  149 EN + 149 IT i18n keys under `housekeeping.*` for every panel
  string + every server-side error slug the emulator may emit.

Wiring (additive only):
- `src/components/MainView.tsx` — `<HousekeepingView />` mounted
  alongside `<ModToolsView />`.
- `src/api/index.ts`, `src/hooks/index.ts`, `src/api/nitro/index.ts`
  — added the `housekeeping` and `awaitMessageEvent` re-exports.

Wire contract: pairs against the Arcturus PR (#120 on
duckietm/Arcturus-Morningstar-Extended) and the renderer PR (#77 on
duckietm/Nitro_Render_V3). Incoming events 9100..9129, outgoing
composers 9200..9207. Permission gate `acc_housekeeping` enforced
server-side; the panel is hidden client-side via
`housekeeping.enabled` in the runtime ui-config.
This commit is contained in:
simoleo89
2026-05-24 16:24:08 +02:00
parent 20ffd5cd7c
commit eeab548917
39 changed files with 5027 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
export * from './useHousekeeping';
export * from './useHousekeepingActions';
export * from './useHousekeepingConfirm';
export * from './useHousekeepingStore';
+17
View File
@@ -0,0 +1,17 @@
import { useHousekeepingActions } from './useHousekeepingActions';
import { useHousekeepingStore } from './useHousekeepingStore';
/**
* Single facade for the in-client housekeeping panel — composes the
* shared store with the imperative actions. Consumers that only need
* one side can still import `useHousekeepingStore` /
* `useHousekeepingActions` directly; this hook exists for the panel
* views that need both.
*/
export const useHousekeeping = () =>
{
const store = useHousekeepingStore();
const actions = useHousekeepingActions();
return { ...store, ...actions };
};
@@ -0,0 +1,467 @@
import { useCallback } from 'react';
import { GetRoomSession, HousekeepingApi, HousekeepingErrorKey, IHousekeepingActionResult, LocalizeText, NotificationBubbleType, validateAmount, validateBanHours, validatePositiveId, validateRank, validateReason } from '../../api';
import { useNotification } from '../notification';
import { useHousekeepingStore } from './useHousekeepingStore';
const SUCCESS_KEY = 'housekeeping.action.success';
const ERROR_KEY = 'housekeeping.action.error';
type ToastFn = (message: string, type: string, imageUrl?: string, internalLink?: string, senderName?: string) => void;
const localizeOrPassthrough = (key: string): string =>
{
if(!key) return '';
if(!key.includes('.')) return key;
const localized = LocalizeText(key);
return (localized === key) ? key : localized;
};
const wrap = async (
runner: () => Promise<IHousekeepingActionResult>,
markPending: () => void,
markDone: (errorKey: string | null, successKey: string | null) => void,
toast: ToastFn,
recordMetric: (action: string, latencyMs: number, isError: boolean) => void,
actionLabel: string
): Promise<IHousekeepingActionResult | null> =>
{
markPending();
const startedAt = typeof performance !== 'undefined' ? performance.now() : Date.now();
const measure = (isError: boolean) =>
{
const endedAt = typeof performance !== 'undefined' ? performance.now() : Date.now();
recordMetric(actionLabel, endedAt - startedAt, isError);
};
try
{
const result = await runner();
if(result && result.ok === false)
{
// Error path: status banner only — the banner is inline
// and stays put until dismissed, more visible than a
// transient bubble for a failure that needs operator
// attention.
markDone(result.message || ERROR_KEY, null);
measure(true);
return result;
}
const successKey = result?.message || SUCCESS_KEY;
markDone(null, successKey);
// Success path also fires a transient toast so the operator
// gets feedback without scanning the banner — banner stays
// as a fallback for users that have bubbles disabled.
toast(localizeOrPassthrough(successKey), NotificationBubbleType.INFO);
measure(false);
return result;
}
catch(error)
{
markDone(String((error as Error)?.message ?? error), null);
measure(true);
return null;
}
};
const validationOr = (key: HousekeepingErrorKey, markDone: (e: string | null, s: string | null) => void): boolean =>
{
if(key === HousekeepingErrorKey.NONE) return true;
markDone(`housekeeping.validation.${ key }`, null);
return false;
};
/**
* Imperative facade for every HK admin action. State (selected
* user/room, status banner) lives in `useHousekeepingStore`; this
* hook reads it for context (e.g. the currently-selected target)
* and writes only the action-pending / status flags via
* `markActionPending` / `markActionDone`. Keeping the read-only
* state in a separate filter would still work, but the singleton
* store keeps invocation simple for the panel views that already
* pull state via `useHousekeepingStore`.
*/
export const useHousekeepingActions = () =>
{
const { selectedUser, selectedRoom, markActionPending, markActionDone, setSelectedUser, setSelectedRoom, recordActionMetric } = useHousekeepingStore();
const { showSingleBubble } = useNotification();
// Stable closure-bound runner so every action below stays a
// one-liner: only the runner thunk + a per-action telemetry
// label change per call site. The label keys into the metrics
// map; a missing label defaults to "anonymous" so untagged calls
// still produce a metric row.
const runAction = useCallback((runner: () => Promise<IHousekeepingActionResult>, actionLabel: string = 'anonymous') =>
wrap(runner, markActionPending, markActionDone, showSingleBubble, recordActionMetric, actionLabel),
[ markActionPending, markActionDone, showSingleBubble, recordActionMetric ]);
// -- USER --------------------------------------------------------
const banUser = useCallback(async (userId: number, reason: string, hours: number) =>
{
if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null;
if(!validationOr(validateReason(reason), markActionDone)) return null;
if(!validationOr(validateBanHours(hours), markActionDone)) return null;
return runAction(() => HousekeepingApi.banUser(userId, reason, hours), 'banUser');
}, [ runAction, markActionDone ]);
const unbanUser = useCallback(async (userId: number) =>
{
if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null;
return runAction(() => HousekeepingApi.unbanUser(userId), 'unbanUser');
}, [ runAction, markActionDone ]);
const muteUser = useCallback(async (userId: number, reason: string, minutes: number) =>
{
if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null;
if(!validationOr(validateReason(reason), markActionDone)) return null;
if(!validationOr(validateBanHours(minutes), markActionDone)) return null;
return runAction(() => HousekeepingApi.muteUser(userId, reason, minutes), 'muteUser');
}, [ runAction, markActionDone ]);
const kickUser = useCallback(async (userId: number, reason: string) =>
{
if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null;
if(!validationOr(validateReason(reason), markActionDone)) return null;
return runAction(() => HousekeepingApi.kickUser(userId, reason), 'kickUser');
}, [ runAction, markActionDone ]);
const forceDisconnectUser = useCallback(async (userId: number, reason: string) =>
{
if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null;
if(!validationOr(validateReason(reason), markActionDone)) return null;
return runAction(() => HousekeepingApi.forceDisconnectUser(userId, reason), 'forceDisconnectUser');
}, [ runAction, markActionDone ]);
const resetUserPassword = useCallback(async (userId: number) =>
{
if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null;
return runAction(() => HousekeepingApi.resetUserPassword(userId), 'resetUserPassword');
}, [ runAction, markActionDone ]);
const setUserRank = useCallback(async (userId: number, rank: number) =>
{
if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null;
if(!validationOr(validateRank(rank), markActionDone)) return null;
const result = await runAction(() => HousekeepingApi.setUserRank(userId, rank), 'setUserRank');
if(result && result.ok !== false && selectedUser && selectedUser.id === userId)
{
setSelectedUser({ ...selectedUser, rank });
}
return result;
}, [ runAction, markActionDone, selectedUser, setSelectedUser ]);
const tradeLockUser = useCallback(async (userId: number, hours: number, reason: string) =>
{
if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null;
if(!validationOr(validateReason(reason), markActionDone)) return null;
if(!validationOr(validateBanHours(hours), markActionDone)) return null;
return runAction(() => HousekeepingApi.tradeLockUser(userId, hours, reason), 'tradeLockUser');
}, [ runAction, markActionDone ]);
// -- ROOM --------------------------------------------------------
const openRoom = useCallback(async (roomId: number) =>
{
if(!validationOr(validatePositiveId(roomId, 'room'), markActionDone)) return null;
const result = await runAction(() => HousekeepingApi.openRoom(roomId), 'openRoom');
if(result && result.ok !== false && selectedRoom && selectedRoom.id === roomId)
{
setSelectedRoom({ ...selectedRoom, isLocked: false });
}
return result;
}, [ runAction, markActionDone, selectedRoom, setSelectedRoom ]);
const closeRoom = useCallback(async (roomId: number) =>
{
if(!validationOr(validatePositiveId(roomId, 'room'), markActionDone)) return null;
const result = await runAction(() => HousekeepingApi.closeRoom(roomId), 'closeRoom');
if(result && result.ok !== false && selectedRoom && selectedRoom.id === roomId)
{
setSelectedRoom({ ...selectedRoom, isLocked: true });
}
return result;
}, [ runAction, markActionDone, selectedRoom, setSelectedRoom ]);
const muteRoom = useCallback(async (roomId: number, minutes: number) =>
{
if(!validationOr(validatePositiveId(roomId, 'room'), markActionDone)) return null;
if(!validationOr(validateBanHours(minutes), markActionDone)) return null;
return runAction(() => HousekeepingApi.muteRoom(roomId, minutes), 'muteRoom');
}, [ runAction, markActionDone ]);
const kickAllFromRoom = useCallback(async (roomId: number) =>
{
if(!validationOr(validatePositiveId(roomId, 'room'), markActionDone)) return null;
return runAction(() => HousekeepingApi.kickAllFromRoom(roomId), 'kickAllFromRoom');
}, [ runAction, markActionDone ]);
const transferRoomOwnership = useCallback(async (roomId: number, newOwnerId: number) =>
{
if(!validationOr(validatePositiveId(roomId, 'room'), markActionDone)) return null;
if(!validationOr(validatePositiveId(newOwnerId, 'user'), markActionDone)) return null;
return runAction(() => HousekeepingApi.transferRoomOwnership(roomId, newOwnerId), 'transferRoomOwnership');
}, [ runAction, markActionDone ]);
const deleteRoom = useCallback(async (roomId: number) =>
{
if(!validationOr(validatePositiveId(roomId, 'room'), markActionDone)) return null;
const result = await runAction(() => HousekeepingApi.deleteRoom(roomId), 'deleteRoom');
if(result && result.ok !== false && selectedRoom && selectedRoom.id === roomId)
{
setSelectedRoom(null);
}
return result;
}, [ runAction, markActionDone, selectedRoom, setSelectedRoom ]);
// -- ECONOMY -----------------------------------------------------
const giveCredits = useCallback(async (userId: number, amount: number) =>
{
if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null;
if(!validationOr(validateAmount(amount), markActionDone)) return null;
return runAction(() => HousekeepingApi.giveCredits(userId, amount), 'giveCredits');
}, [ runAction, markActionDone ]);
const giveDuckets = useCallback(async (userId: number, amount: number) =>
{
if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null;
if(!validationOr(validateAmount(amount), markActionDone)) return null;
return runAction(() => HousekeepingApi.giveDuckets(userId, amount), 'giveDuckets');
}, [ runAction, markActionDone ]);
const giveDiamonds = useCallback(async (userId: number, amount: number) =>
{
if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null;
if(!validationOr(validateAmount(amount), markActionDone)) return null;
return runAction(() => HousekeepingApi.giveDiamonds(userId, amount), 'giveDiamonds');
}, [ runAction, markActionDone ]);
const grantItem = useCallback(async (userId: number, itemId: number, quantity: number) =>
{
if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null;
if(!validationOr(validatePositiveId(itemId, 'user'), markActionDone)) return null;
if(!validationOr(validateAmount(quantity), markActionDone)) return null;
return runAction(() => HousekeepingApi.grantItem(userId, itemId, quantity), 'grantItem');
}, [ runAction, markActionDone ]);
const setHcSubscription = useCallback(async (userId: number, days: number) =>
{
if(!validationOr(validatePositiveId(userId, 'user'), markActionDone)) return null;
if(!validationOr(validateAmount(days), markActionDone)) return null;
return runAction(() => HousekeepingApi.setHcSubscription(userId, days), 'setHcSubscription');
}, [ runAction, markActionDone ]);
const sendHotelAlert = useCallback(async (message: string) =>
{
if(!validationOr(validateReason(message), markActionDone)) return null;
return runAction(() => HousekeepingApi.sendHotelAlert(message), 'sendHotelAlert');
}, [ runAction, markActionDone ]);
// -- LIVE IN-ROOM ACTIONS ---------------------------------------
// These bridge directly to the active RoomSession so the
// sanction lands on the current game state (no server roundtrip
// through the HTTP layer). Use for "the user is here, right
// now" sanctions; persistent admin actions still go through the
// HTTP API above.
const kickFromCurrentRoom = useCallback((webUserId: number) =>
{
const session = GetRoomSession();
if(!session)
{
markActionDone('housekeeping.live.no_room', null);
return false;
}
try
{
session.sendKickMessage(webUserId);
markActionDone(null, 'housekeeping.live.kicked');
showSingleBubble(localizeOrPassthrough('housekeeping.live.kicked'), NotificationBubbleType.INFO);
return true;
}
catch(error)
{
markActionDone(String((error as Error)?.message ?? error), null);
return false;
}
}, [ markActionDone, showSingleBubble ]);
const banFromCurrentRoom = useCallback((webUserId: number, severity: 'hour' | 'day' | 'perm' = 'hour') =>
{
const session = GetRoomSession();
if(!session)
{
markActionDone('housekeeping.live.no_room', null);
return false;
}
const code = severity === 'perm' ? 'RWUAM_BAN_USER_PERM' : severity === 'day' ? 'RWUAM_BAN_USER_DAY' : 'RWUAM_BAN_USER_HOUR';
try
{
session.sendBanMessage(webUserId, code);
markActionDone(null, 'housekeeping.live.banned');
showSingleBubble(localizeOrPassthrough('housekeeping.live.banned'), NotificationBubbleType.INFO);
return true;
}
catch(error)
{
markActionDone(String((error as Error)?.message ?? error), null);
return false;
}
}, [ markActionDone, showSingleBubble ]);
// -- BULK HTTP ACTIONS ------------------------------------------
// Loop with Promise.allSettled so a single failure doesn't abort
// the rest of the batch. Aggregated success/failure counts land
// in the status banner; per-user errors fall through to the audit
// log on the server side.
const runBulk = useCallback(async (
userIds: ReadonlyArray<number>,
single: (id: number) => Promise<IHousekeepingActionResult | null>,
actionLabel: string
): Promise<{ ok: number; failed: number }> =>
{
if(userIds.length === 0) return { ok: 0, failed: 0 };
markActionPending();
const startedAt = typeof performance !== 'undefined' ? performance.now() : Date.now();
const settled = await Promise.allSettled(userIds.map(id => single(id)));
let ok = 0;
let failed = 0;
for(const outcome of settled)
{
if(outcome.status === 'fulfilled' && outcome.value && outcome.value.ok !== false) ok++;
else failed++;
}
// One metric sample per bulk run rather than per user — the
// bulk timing is what the operator cares about. Bucket suffix
// `:bulk` keeps the metric separate from the matching single
// action in the telemetry panel.
const endedAt = typeof performance !== 'undefined' ? performance.now() : Date.now();
recordActionMetric(`${ actionLabel }:bulk`, endedAt - startedAt, failed > 0);
const summaryKey = failed === 0 ? 'housekeeping.bulk.success' : 'housekeeping.bulk.partial';
markActionDone(failed > 0 && ok === 0 ? 'housekeeping.bulk.failed' : null, failed === 0 ? summaryKey : null);
showSingleBubble(`${ localizeOrPassthrough('housekeeping.bulk.done') }${ ok }/${ userIds.length }`, NotificationBubbleType.INFO);
return { ok, failed };
}, [ markActionPending, markActionDone, showSingleBubble, recordActionMetric ]);
const banUsersBulk = useCallback((userIds: ReadonlyArray<number>, reason: string, hours: number) =>
runBulk(userIds, id => HousekeepingApi.banUser(id, reason, hours), 'banUser'),
[ runBulk ]);
const kickUsersBulk = useCallback((userIds: ReadonlyArray<number>, reason: string) =>
runBulk(userIds, id => HousekeepingApi.kickUser(id, reason), 'kickUser'),
[ runBulk ]);
const muteUsersBulk = useCallback((userIds: ReadonlyArray<number>, reason: string, minutes: number) =>
runBulk(userIds, id => HousekeepingApi.muteUser(id, reason, minutes), 'muteUser'),
[ runBulk ]);
const muteInCurrentRoom = useCallback((webUserId: number, minutes: number) =>
{
const session = GetRoomSession();
if(!session)
{
markActionDone('housekeeping.live.no_room', null);
return false;
}
try
{
session.sendMuteMessage(webUserId, minutes);
markActionDone(null, 'housekeeping.live.muted');
showSingleBubble(localizeOrPassthrough('housekeeping.live.muted'), NotificationBubbleType.INFO);
return true;
}
catch(error)
{
markActionDone(String((error as Error)?.message ?? error), null);
return false;
}
}, [ markActionDone, showSingleBubble ]);
return {
banUser,
unbanUser,
muteUser,
kickUser,
forceDisconnectUser,
resetUserPassword,
setUserRank,
tradeLockUser,
openRoom,
closeRoom,
muteRoom,
kickAllFromRoom,
transferRoomOwnership,
deleteRoom,
giveCredits,
giveDuckets,
giveDiamonds,
grantItem,
setHcSubscription,
sendHotelAlert,
kickFromCurrentRoom,
banFromCurrentRoom,
muteInCurrentRoom,
banUsersBulk,
kickUsersBulk,
muteUsersBulk
};
};
@@ -0,0 +1,64 @@
/* @vitest-environment jsdom */
import { describe, expect, it } from 'vitest';
/**
* Pure aggregation logic of the bulk path — modelled after the
* `runBulk` reducer inside `useHousekeepingActions.ts`. The hook
* itself is hard to drive cleanly in jsdom because it pulls
* `useBetween`, `useNotification`, and the renderer-SDK mock through
* a long transitive import chain. The actual aggregation is the
* interesting bit and isolating it keeps the test fast + readable.
*
* Mirror of the production logic — if the hook's reducer changes,
* this test should change with it.
*/
type Outcome = PromiseSettledResult<{ ok: boolean } | null>;
const aggregate = (settled: Outcome[]): { ok: number; failed: number } =>
{
let ok = 0;
let failed = 0;
for(const outcome of settled)
{
if(outcome.status === 'fulfilled' && outcome.value && outcome.value.ok !== false) ok++;
else failed++;
}
return { ok, failed };
};
const ok = (): Outcome => ({ status: 'fulfilled', value: { ok: true } });
const fail = (): Outcome => ({ status: 'fulfilled', value: { ok: false } });
const rejected = (): Outcome => ({ status: 'rejected', reason: new Error('net') });
const nullValue = (): Outcome => ({ status: 'fulfilled', value: null });
describe('bulk aggregation (mirrors useHousekeepingActions.runBulk)', () =>
{
it('counts only `ok: true` results as success', () =>
{
expect(aggregate([ ok(), ok(), ok() ])).toEqual({ ok: 3, failed: 0 });
});
it('counts `ok: false` results as failures', () =>
{
expect(aggregate([ ok(), fail(), ok() ])).toEqual({ ok: 2, failed: 1 });
});
it('counts rejected promises as failures (not crashes)', () =>
{
expect(aggregate([ ok(), rejected(), ok() ])).toEqual({ ok: 2, failed: 1 });
});
it('counts null-result responses as failures (server returned nothing meaningful)', () =>
{
expect(aggregate([ ok(), nullValue(), ok() ])).toEqual({ ok: 2, failed: 1 });
});
it('returns 0/0 for an empty input — no division-by-zero', () =>
{
expect(aggregate([])).toEqual({ ok: 0, failed: 0 });
});
});
@@ -0,0 +1,32 @@
import { useCallback } from 'react';
import { LocalizeText } from '../../api';
import { useNotification } from '../notification';
/**
* Themed confirmation wrapper around `useNotification().showConfirm`.
*
* Destructive HK actions (delete room, kick-all, bulk ban) used to
* call `window.confirm` directly — that's a system-modal that breaks
* out of the client visually and doesn't honor the LocalizeText
* dictionary. `useHousekeepingConfirm` swaps in the in-client
* NotificationConfirm modal, with the HK button labels and a
* sensible default title.
*
* Returns a single function `confirm(message, onConfirm)` to keep
* the call sites tight. Pass an `options.confirmText` override when
* the action needs a custom label (e.g. "Delete forever" instead of
* the generic confirm).
*/
export const useHousekeepingConfirm = () =>
{
const { showConfirm } = useNotification();
return useCallback((message: string, onConfirm: () => void, options: { confirmText?: string; cancelText?: string; title?: string } = {}) =>
{
const confirmText = options.confirmText ?? LocalizeText('housekeeping.confirm.proceed');
const cancelText = options.cancelText ?? LocalizeText('housekeeping.confirm.cancel');
const title = options.title ?? LocalizeText('housekeeping.confirm.title');
showConfirm(message, onConfirm, () => {}, confirmText, cancelText, title);
}, [ showConfirm ]);
};
@@ -0,0 +1,513 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useBetween } from 'use-between';
import {
emptySample, GetConfigurationValue, HousekeepingApi, HousekeepingTabId, IHousekeepingActionLogEntry,
IHousekeepingDashboard, IHousekeepingRoom, IHousekeepingRoomSummary, IHousekeepingUser,
IHousekeepingUserSummary, loadRecentLookups, persistRecentLookups, pushRecentLookup, recordSample,
RecentLookupEntry
} from '../../api';
import { useLocalStorage } from '../useLocalStorage';
const AUDIT_POLL_DEFAULT_MS = 30000;
const AUDIT_POLL_MIN_MS = 5000;
const ACTION_LOG_LIMIT = 100;
const AUTOCOMPLETE_DEBOUNCE_MS = 250;
const AUTOCOMPLETE_MIN_PREFIX = 2;
const useHousekeepingStoreInner = () =>
{
const [ isVisible, setIsVisible ] = useState(false);
// Last-tab is persisted per user (useLocalStorage auto-scopes the key
// by userId from the URL) so reopening the panel lands on the same
// tab the operator was using. HousekeepingView's auto-redirect
// effect handles the case where the persisted tab isn't available
// in the current `housekeeping.mode` (light bounces DASHBOARD → USERS).
const [ activeTab, setActiveTab ] = useLocalStorage<HousekeepingTabId>('nitro.housekeeping.last_tab', HousekeepingTabId.DASHBOARD);
const [ selectedUser, setSelectedUser ] = useState<IHousekeepingUser | null>(null);
const [ selectedRoom, setSelectedRoom ] = useState<IHousekeepingRoom | null>(null);
const [ actionLog, setActionLog ] = useState<IHousekeepingActionLogEntry[]>([]);
const [ isUserLoading, setIsUserLoading ] = useState(false);
const [ isRoomLoading, setIsRoomLoading ] = useState(false);
const [ isActionPending, setIsActionPending ] = useState(false);
const [ lastError, setLastError ] = useState<string | null>(null);
const [ lastSuccess, setLastSuccess ] = useState<string | null>(null);
const [ dashboard, setDashboard ] = useState<IHousekeepingDashboard | null>(null);
const [ isDashboardLoading, setIsDashboardLoading ] = useState(false);
const [ userSuggestions, setUserSuggestions ] = useState<IHousekeepingUserSummary[]>([]);
const [ roomSuggestions, setRoomSuggestions ] = useState<IHousekeepingRoomSummary[]>([]);
const [ recentLookups, setRecentLookups ] = useState<RecentLookupEntry[]>(() => loadRecentLookups());
// Multi-select state for the Users tab. We use an array of ids
// rather than a Set because Zustand-style `useBetween` re-renders
// on referential equality — mutating a Set in place would miss
// updates. Capped via the dedupe in toggleUserSelection.
const [ selectedUserIds, setSelectedUserIds ] = useState<number[]>([]);
// Per-action latency / count / error metrics. Map → triggers a
// new reference on every update so subscribers re-render.
// Capped per-action via `recordSample`'s sliding window so the
// memory footprint is bounded regardless of session length.
const [ metricsByAction, setMetricsByAction ] = useState<Map<string, import('../../api').MetricSample>>(() => new Map());
// Track the most-recent fetch per slot so out-of-order responses don't
// flash stale data into the panel.
const userFetchTokenRef = useRef(0);
const roomFetchTokenRef = useRef(0);
const userSuggestTokenRef = useRef(0);
const roomSuggestTokenRef = useRef(0);
const userSuggestTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const roomSuggestTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const userSuggestAbortRef = useRef<AbortController | null>(null);
const roomSuggestAbortRef = useRef<AbortController | null>(null);
useEffect(() => () =>
{
if(userSuggestTimerRef.current) clearTimeout(userSuggestTimerRef.current);
if(roomSuggestTimerRef.current) clearTimeout(roomSuggestTimerRef.current);
userSuggestAbortRef.current?.abort();
roomSuggestAbortRef.current?.abort();
}, []);
const fetchDashboard = useCallback(async (signal?: AbortSignal) =>
{
setIsDashboardLoading(true);
try
{
const data = await HousekeepingApi.getDashboard(signal);
if(signal?.aborted) return;
setDashboard(data ?? null);
}
catch
{
if(!signal?.aborted) setDashboard(null);
}
finally
{
if(!signal?.aborted) setIsDashboardLoading(false);
}
}, []);
const fetchAuditLog = useCallback(async (signal?: AbortSignal) =>
{
try
{
const entries = await HousekeepingApi.listActionLog(ACTION_LOG_LIMIT, signal);
if(signal?.aborted) return;
setActionLog(Array.isArray(entries) ? entries : []);
}
catch
{
if(!signal?.aborted) setActionLog([]);
}
}, []);
useEffect(() =>
{
if(!isVisible) return;
const controller = new AbortController();
// Refresh dashboard + audit log every time the panel opens so
// a HK who's been away doesn't see a stale snapshot. We
// INTENTIONALLY call the async fetchers from inside the effect
// — they're external-system calls (HTTP + signal-aware abort)
// not derived state, which is exactly the case
// set-state-in-effect's docs carve out. The setState inside
// the fetchers lands in a microtask after the await, not in
// this synchronous effect body.
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchDashboard(controller.signal);
fetchAuditLog(controller.signal);
return () => controller.abort();
}, [ isVisible, fetchDashboard, fetchAuditLog ]);
// Live audit polling. While the panel is open AND the document
// is visible, repoll the audit endpoint on a configurable
// interval (`housekeeping.audit.poll_interval_ms`, default 30s,
// floor 5s). Set to 0 to disable. Pauses entirely on tab-hidden
// so a stack of background sessions doesn't hammer the admin
// endpoint.
//
// This is intentionally HTTP polling rather than `useMessageEvent`
// — the latter would require a new HousekeepingAuditPushEvent
// composer/parser in the renderer SDK, which is out of scope for
// a client-only change. Drop-in upgrade path documented in
// CLAUDE.md when the wire protocol catches up.
useEffect(() =>
{
if(!isVisible) return;
const configured = GetConfigurationValue<number>('housekeeping.audit.poll_interval_ms', AUDIT_POLL_DEFAULT_MS);
const intervalMs = typeof configured === 'number' && configured >= AUDIT_POLL_MIN_MS ? configured : (configured === 0 ? 0 : AUDIT_POLL_DEFAULT_MS);
if(intervalMs === 0) return; // operator opted out via config
let handle: ReturnType<typeof setInterval> | null = null;
const start = () =>
{
if(handle) return;
handle = setInterval(() =>
{
if(typeof document !== 'undefined' && document.visibilityState !== 'visible') return;
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchAuditLog();
}, intervalMs);
};
const stop = () =>
{
if(handle) clearInterval(handle);
handle = null;
};
const onVisibility = () =>
{
if(document.visibilityState === 'visible') start();
else stop();
};
start();
if(typeof document !== 'undefined') document.addEventListener('visibilitychange', onVisibility);
return () =>
{
stop();
if(typeof document !== 'undefined') document.removeEventListener('visibilitychange', onVisibility);
};
}, [ isVisible, fetchAuditLog ]);
const clearStatus = useCallback(() =>
{
setLastError(null);
setLastSuccess(null);
}, []);
const rememberLookup = useCallback((entry: RecentLookupEntry) =>
{
setRecentLookups(prev =>
{
const next = pushRecentLookup(prev, entry);
persistRecentLookups(next);
return next;
});
}, []);
const lookupUserByName = useCallback(async (username: string) =>
{
const token = ++userFetchTokenRef.current;
setIsUserLoading(true);
clearStatus();
try
{
const result = await HousekeepingApi.findUserByName(username);
if(userFetchTokenRef.current !== token) return null;
setSelectedUser(result ?? null);
if(result) rememberLookup({ kind: 'user', id: result.id, label: result.username, at: Date.now() });
else setLastError('housekeeping.user.not_found');
return result;
}
catch(error)
{
if(userFetchTokenRef.current === token) setLastError(String((error as Error)?.message ?? error));
return null;
}
finally
{
if(userFetchTokenRef.current === token) setIsUserLoading(false);
}
}, [ clearStatus, rememberLookup ]);
/**
* Optimistic seed used when the operator clicks an avatar in-room —
* the renderer already knows id / name / figure, so we surface those
* immediately while the findUserById packet enriches the rest
* (credits, email, ipLast, …) in the background. If the packet
* times out we keep the hint so actions still work on the userId.
*/
const seedUserFromAvatar = useCallback((userId: number, username: string, figure: string) =>
{
if(!Number.isFinite(userId) || userId <= 0) return;
const hint: IHousekeepingUser = {
id: userId,
username: username || '',
motto: '',
figure: figure || '',
rank: 0,
rankName: '',
online: true,
lastOnlineAt: null,
creditsBalance: 0,
ducketsBalance: 0,
diamondsBalance: 0,
email: '',
ipLast: '',
isBanned: false,
isMuted: false,
isTradeLocked: false
};
setSelectedUser(hint);
rememberLookup({ kind: 'user', id: userId, label: hint.username, at: Date.now() });
}, [ rememberLookup ]);
const lookupUserById = useCallback(async (userId: number) =>
{
const token = ++userFetchTokenRef.current;
setIsUserLoading(true);
clearStatus();
try
{
const result = await HousekeepingApi.findUserById(userId);
if(userFetchTokenRef.current !== token) return null;
// Don't blank the optimistic seed when the lookup times out
// or returns null — operators clicking in-room want the
// hint to stay visible so the action buttons remain usable.
if(result)
{
setSelectedUser(result);
rememberLookup({ kind: 'user', id: result.id, label: result.username, at: Date.now() });
}
else
{
setLastError('housekeeping.user.not_found');
}
return result;
}
catch(error)
{
if(userFetchTokenRef.current === token) setLastError(String((error as Error)?.message ?? error));
return null;
}
finally
{
if(userFetchTokenRef.current === token) setIsUserLoading(false);
}
}, [ clearStatus, rememberLookup ]);
const lookupRoomById = useCallback(async (roomId: number) =>
{
const token = ++roomFetchTokenRef.current;
setIsRoomLoading(true);
clearStatus();
try
{
const result = await HousekeepingApi.findRoomById(roomId);
if(roomFetchTokenRef.current !== token) return null;
setSelectedRoom(result ?? null);
if(result) rememberLookup({ kind: 'room', id: result.id, label: result.name, at: Date.now() });
else setLastError('housekeeping.room.not_found');
return result;
}
catch(error)
{
if(roomFetchTokenRef.current === token) setLastError(String((error as Error)?.message ?? error));
return null;
}
finally
{
if(roomFetchTokenRef.current === token) setIsRoomLoading(false);
}
}, [ clearStatus, rememberLookup ]);
const requestUserSuggestions = useCallback((prefix: string) =>
{
if(userSuggestTimerRef.current) clearTimeout(userSuggestTimerRef.current);
const trimmed = (prefix || '').trim();
if(trimmed.length < AUTOCOMPLETE_MIN_PREFIX)
{
userSuggestAbortRef.current?.abort();
setUserSuggestions([]);
return;
}
userSuggestTimerRef.current = setTimeout(async () =>
{
userSuggestAbortRef.current?.abort();
const controller = new AbortController();
userSuggestAbortRef.current = controller;
const token = ++userSuggestTokenRef.current;
try
{
const list = await HousekeepingApi.searchUsers(trimmed, controller.signal);
if(controller.signal.aborted || userSuggestTokenRef.current !== token) return;
setUserSuggestions(Array.isArray(list) ? list : []);
}
catch
{
if(!controller.signal.aborted) setUserSuggestions([]);
}
}, AUTOCOMPLETE_DEBOUNCE_MS);
}, []);
const requestRoomSuggestions = useCallback((prefix: string) =>
{
if(roomSuggestTimerRef.current) clearTimeout(roomSuggestTimerRef.current);
const trimmed = (prefix || '').trim();
if(trimmed.length < AUTOCOMPLETE_MIN_PREFIX)
{
roomSuggestAbortRef.current?.abort();
setRoomSuggestions([]);
return;
}
roomSuggestTimerRef.current = setTimeout(async () =>
{
roomSuggestAbortRef.current?.abort();
const controller = new AbortController();
roomSuggestAbortRef.current = controller;
const token = ++roomSuggestTokenRef.current;
try
{
const list = await HousekeepingApi.searchRooms(trimmed, controller.signal);
if(controller.signal.aborted || roomSuggestTokenRef.current !== token) return;
setRoomSuggestions(Array.isArray(list) ? list : []);
}
catch
{
if(!controller.signal.aborted) setRoomSuggestions([]);
}
}, AUTOCOMPLETE_DEBOUNCE_MS);
}, []);
const markActionPending = useCallback(() => setIsActionPending(true), []);
const markActionDone = useCallback((errorKey: string | null, successKey: string | null) =>
{
setIsActionPending(false);
setLastError(errorKey);
setLastSuccess(successKey);
}, []);
const closePanel = useCallback(() =>
{
setIsVisible(false);
clearStatus();
}, [ clearStatus ]);
const togglePanel = useCallback(() => setIsVisible(value => !value), []);
const toggleUserSelection = useCallback((userId: number) =>
{
setSelectedUserIds(prev =>
{
if(prev.includes(userId)) return prev.filter(id => id !== userId);
return [ ...prev, userId ];
});
}, []);
const clearUserSelection = useCallback(() => setSelectedUserIds([]), []);
const recordActionMetric = useCallback((action: string, latencyMs: number, isError: boolean) =>
{
setMetricsByAction(prev =>
{
const next = new Map(prev);
const current = next.get(action) ?? emptySample();
next.set(action, recordSample(current, latencyMs, isError));
return next;
});
}, []);
const resetActionMetrics = useCallback(() => setMetricsByAction(new Map()), []);
return {
isVisible,
setIsVisible,
togglePanel,
closePanel,
activeTab,
setActiveTab,
selectedUser,
setSelectedUser,
selectedRoom,
setSelectedRoom,
actionLog,
setActionLog,
isUserLoading,
isRoomLoading,
isActionPending,
markActionPending,
markActionDone,
lastError,
lastSuccess,
clearStatus,
lookupUserByName,
lookupUserById,
seedUserFromAvatar,
lookupRoomById,
dashboard,
isDashboardLoading,
refreshDashboard: fetchDashboard,
refreshAuditLog: fetchAuditLog,
userSuggestions,
roomSuggestions,
requestUserSuggestions,
requestRoomSuggestions,
recentLookups,
selectedUserIds,
toggleUserSelection,
clearUserSelection,
metricsByAction,
recordActionMetric,
resetActionMetrics
};
};
/**
* Singleton store backing the housekeeping panel. State, lookups,
* dashboard/audit fetches, autocomplete + recent-lookups
* persistence all live in one `useBetween` closure so every tab
* shares the same view of the world — and reopening the panel
* doesn't re-fetch state that's already in memory.
*/
export const useHousekeepingStore = () => useBetween(useHousekeepingStoreInner);
+1
View File
@@ -8,6 +8,7 @@ export * from './friends';
export * from './game-center';
export * from './groups';
export * from './help';
export * from './housekeeping';
export * from './inventory';
export * from './mod-tools';
export * from './navigator';