mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-20 07:26:19 +00:00
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:
@@ -0,0 +1,4 @@
|
||||
export * from './useHousekeeping';
|
||||
export * from './useHousekeepingActions';
|
||||
export * from './useHousekeepingConfirm';
|
||||
export * from './useHousekeepingStore';
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user