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
@@ -0,0 +1,149 @@
{
"housekeeping.title": "Housekeeping",
"housekeeping.mode.light": "Light",
"housekeeping.tab.dashboard": "Dashboard",
"housekeeping.tab.users": "Users",
"housekeeping.tab.rooms": "Rooms",
"housekeeping.tab.economy": "Economy",
"housekeeping.tab.audit": "Audit",
"housekeeping.confirm.title": "Confirm action",
"housekeeping.confirm.proceed": "Proceed",
"housekeeping.confirm.cancel": "Cancel",
"housekeeping.status.dismiss": "Dismiss",
"housekeeping.action.pending": "Action pending…",
"housekeeping.action.success": "Action completed",
"housekeeping.action.error": "Action failed",
"housekeeping.error.invalid_input": "Invalid input — check the user id and the value you provided.",
"housekeeping.error.user_not_found": "User not found.",
"housekeeping.error.user_offline": "User is offline — this action only works on online users.",
"housekeeping.error.target_unkickable": "This user cannot be kicked.",
"housekeeping.error.ban_failed": "Ban could not be applied — server refused the request.",
"housekeeping.error.no_active_ban": "No active ban to clear for this user.",
"housekeeping.error.rank_not_found": "Rank not found — pick a rank that exists in permission_ranks.",
"housekeeping.error.db_failed": "Database error — see the emulator log for the SQL exception.",
"housekeeping.error.hash_failed": "Could not hash the new password — SHA-256 unavailable on this JVM.",
"housekeeping.error.room_not_found": "Room not found.",
"housekeeping.error.room_action_failed": "Room action could not be applied.",
"housekeeping.error.new_owner_not_found": "New owner not found.",
"housekeeping.error.economy_failed": "Economy action could not be applied — check the user id and the amount.",
"housekeeping.error.alert_empty": "Hotel alert message cannot be empty.",
"housekeeping.action.ban_h": "Ban %h%h",
"housekeeping.action.mute_min": "Mute %m%m",
"housekeeping.action.trade_lock_h": "Trade lock %h%h",
"housekeeping.action.kick": "Kick",
"housekeeping.action.unban": "Unban",
"housekeeping.action.force_disconnect": "Force disconnect",
"housekeeping.action.set_rank": "Set rank",
"housekeeping.action.reset_password": "Reset password",
"housekeeping.user.search.placeholder": "Search by username…",
"housekeeping.user.search.button": "Search",
"housekeeping.user.clear": "Clear selection",
"housekeeping.user.none": "No user selected — search above to pick one.",
"housekeeping.user.not_found": "User not found.",
"housekeeping.user.credits": "Credits",
"housekeeping.user.duckets": "Duckets / pixels",
"housekeeping.user.diamonds": "Diamonds",
"housekeeping.user.audit_hint": "All actions are logged in the audit tab.",
"housekeeping.user.live.label": "Live (in current room)",
"housekeeping.user.live.kick": "Kick",
"housekeeping.user.live.mute_2m": "Mute 2m",
"housekeeping.user.live.mute_10m": "Mute 10m",
"housekeeping.user.live.ban_h": "Ban 1h",
"housekeeping.user.live.ban_d": "Ban 1d",
"housekeeping.room.search.placeholder": "Room ID…",
"housekeeping.room.search.button": "Search",
"housekeeping.room.clear": "Clear selection",
"housekeeping.room.none": "No room selected — enter an ID above.",
"housekeeping.room.not_found": "Room not found.",
"housekeeping.room.open": "Open",
"housekeeping.room.close": "Close",
"housekeeping.room.mute_min": "Mute %m%m",
"housekeeping.room.kick_all": "Kick all",
"housekeeping.room.kick_all.confirm": "Kick every user currently in the room?",
"housekeeping.room.delete": "Delete room",
"housekeeping.room.delete.confirm": "Delete this room and all its furniture permanently?",
"housekeeping.room.transfer": "Transfer",
"housekeeping.room.transfer.label": "Transfer ownership",
"housekeeping.room.transfer.new_owner": "New owner ID",
"housekeeping.economy.select_user": "Pick a user in the Users tab first.",
"housekeeping.economy.target": "Target: %username% (#%id%)",
"housekeeping.economy.give_credits": "Give credits",
"housekeeping.economy.give_duckets": "Give duckets",
"housekeeping.economy.give_diamonds": "Give diamonds",
"housekeeping.economy.grant_item": "Grant item",
"housekeeping.economy.grant_item.label": "Grant catalog item",
"housekeeping.economy.item_id": "Item ID",
"housekeeping.economy.item_quantity": "Qty",
"housekeeping.economy.set_hc_days": "Set HC days",
"housekeeping.hotel.alert.label": "Hotel-wide alert",
"housekeeping.hotel.alert.placeholder": "Message broadcast to every connected user…",
"housekeeping.hotel.alert.send": "Send to hotel",
"housekeeping.hotel.alert.confirm": "Broadcast %count%-character alert to every connected user?",
"housekeeping.dashboard.title": "Overview",
"housekeeping.dashboard.refresh": "Refresh",
"housekeeping.dashboard.loading": "Loading dashboard…",
"housekeeping.dashboard.unavailable": "Dashboard unavailable — check the admin endpoint.",
"housekeeping.dashboard.online": "Online",
"housekeeping.dashboard.total_users": "%count% total",
"housekeeping.dashboard.rooms_active": "Active rooms",
"housekeeping.dashboard.total_rooms": "%count% total",
"housekeeping.dashboard.peak_today": "Peak today",
"housekeeping.dashboard.peak_alltime": "All-time peak %count%",
"housekeeping.dashboard.pending_tickets": "Tickets",
"housekeeping.dashboard.sanctions_24h": "%count% sanctions / 24h",
"housekeeping.dashboard.server": "Server",
"housekeeping.dashboard.recent_sanctions": "Recent sanctions",
"housekeeping.dashboard.recent_lookups": "Recent lookups",
"housekeeping.audit.title": "Audit log",
"housekeeping.audit.refresh": "Refresh",
"housekeeping.audit.filter.all": "All",
"housekeeping.audit.filter.users": "Users",
"housekeeping.audit.filter.rooms": "Rooms",
"housekeeping.audit.filter.hotel": "Hotel",
"housekeeping.audit.search.placeholder": "Search actor / target / action…",
"housekeeping.audit.empty": "No audit entries yet.",
"housekeeping.audit.no_match": "No entries match the current filters.",
"housekeeping.field.reason": "Reason",
"housekeeping.field.reason.placeholder": "Free-text reason (optional)",
"housekeeping.field.duration": "Duration",
"housekeeping.reason.default": "No reason provided.",
"housekeeping.menu.send_to_hk": "Send to HK",
"housekeeping.bulk.done": "Bulk done",
"housekeeping.bulk.success": "All bulk actions succeeded.",
"housekeeping.bulk.partial": "Bulk completed with some failures.",
"housekeeping.bulk.failed": "Every bulk action failed.",
"housekeeping.bulk.confirm": "Apply %action% to %count% selected users?",
"housekeeping.bulk.label": "%count% selected",
"housekeeping.bulk.clear": "Clear selection",
"housekeeping.bulk.apply": "Apply to selected",
"housekeeping.telemetry.title": "Telemetry",
"housekeeping.telemetry.empty": "No actions observed yet.",
"housekeeping.telemetry.reset": "Reset metrics",
"housekeeping.live.no_room": "No active room session.",
"housekeeping.live.kicked": "Kicked from room.",
"housekeeping.live.banned": "Banned from room.",
"housekeeping.live.muted": "Muted in room.",
"housekeeping.validation.empty_username": "Username can't be empty.",
"housekeeping.validation.invalid_user_id": "Invalid user ID.",
"housekeeping.validation.invalid_room_id": "Invalid room ID.",
"housekeeping.validation.invalid_amount": "Invalid amount.",
"housekeeping.validation.amount_too_large": "Amount exceeds the safety cap.",
"housekeeping.validation.empty_reason": "Reason can't be empty.",
"housekeeping.validation.invalid_hours": "Invalid duration in hours.",
"housekeeping.validation.invalid_rank": "Invalid rank — must be between 1 and 12."
}
@@ -0,0 +1,149 @@
{
"housekeeping.title": "Housekeeping",
"housekeeping.mode.light": "Light",
"housekeeping.tab.dashboard": "Dashboard",
"housekeeping.tab.users": "Utenti",
"housekeeping.tab.rooms": "Stanze",
"housekeeping.tab.economy": "Economia",
"housekeeping.tab.audit": "Audit",
"housekeeping.confirm.title": "Conferma azione",
"housekeeping.confirm.proceed": "Procedi",
"housekeeping.confirm.cancel": "Annulla",
"housekeeping.status.dismiss": "Chiudi",
"housekeeping.action.pending": "Azione in corso…",
"housekeeping.action.success": "Azione completata",
"housekeeping.action.error": "Azione fallita",
"housekeeping.error.invalid_input": "Input non valido — controlla l'id utente e il valore inserito.",
"housekeeping.error.user_not_found": "Utente non trovato.",
"housekeeping.error.user_offline": "Utente offline — questa azione funziona solo sugli utenti online.",
"housekeeping.error.target_unkickable": "Questo utente non può essere espulso.",
"housekeeping.error.ban_failed": "Impossibile applicare il ban — il server ha rifiutato la richiesta.",
"housekeeping.error.no_active_ban": "Nessun ban attivo da rimuovere per questo utente.",
"housekeeping.error.rank_not_found": "Rank non trovato — scegli un rank presente in permission_ranks.",
"housekeeping.error.db_failed": "Errore database — controlla il log dell'emulatore per l'eccezione SQL.",
"housekeeping.error.hash_failed": "Impossibile generare l'hash della nuova password — SHA-256 non disponibile sulla JVM.",
"housekeeping.error.room_not_found": "Stanza non trovata.",
"housekeeping.error.room_action_failed": "Impossibile applicare l'azione sulla stanza.",
"housekeeping.error.new_owner_not_found": "Nuovo proprietario non trovato.",
"housekeeping.error.economy_failed": "Impossibile applicare l'azione economy — controlla id utente e importo.",
"housekeeping.error.alert_empty": "Il messaggio di hotel-alert non può essere vuoto.",
"housekeeping.action.ban_h": "Ban %h%h",
"housekeeping.action.mute_min": "Mute %m%m",
"housekeeping.action.trade_lock_h": "Blocco scambi %h%h",
"housekeeping.action.kick": "Kick",
"housekeeping.action.unban": "Sblocca",
"housekeeping.action.force_disconnect": "Forza disconnessione",
"housekeeping.action.set_rank": "Imposta rank",
"housekeeping.action.reset_password": "Reset password",
"housekeeping.user.search.placeholder": "Cerca per nome…",
"housekeeping.user.search.button": "Cerca",
"housekeeping.user.clear": "Deseleziona",
"housekeeping.user.none": "Nessun utente selezionato — cerca uno qui sopra.",
"housekeeping.user.not_found": "Utente non trovato.",
"housekeeping.user.credits": "Crediti",
"housekeeping.user.duckets": "Pixel / duckets",
"housekeeping.user.diamonds": "Diamanti",
"housekeeping.user.audit_hint": "Ogni azione viene registrata nel tab Audit.",
"housekeeping.user.live.label": "Live (stanza corrente)",
"housekeeping.user.live.kick": "Kick",
"housekeeping.user.live.mute_2m": "Mute 2m",
"housekeeping.user.live.mute_10m": "Mute 10m",
"housekeeping.user.live.ban_h": "Ban 1h",
"housekeeping.user.live.ban_d": "Ban 1g",
"housekeeping.room.search.placeholder": "ID stanza…",
"housekeeping.room.search.button": "Cerca",
"housekeeping.room.clear": "Deseleziona",
"housekeeping.room.none": "Nessuna stanza selezionata — inserisci un ID.",
"housekeeping.room.not_found": "Stanza non trovata.",
"housekeeping.room.open": "Apri",
"housekeeping.room.close": "Chiudi",
"housekeeping.room.mute_min": "Mute %m%m",
"housekeeping.room.kick_all": "Kick tutti",
"housekeeping.room.kick_all.confirm": "Cacciare ogni utente attualmente nella stanza?",
"housekeeping.room.delete": "Elimina stanza",
"housekeeping.room.delete.confirm": "Eliminare la stanza e tutti i suoi mobili in modo permanente?",
"housekeeping.room.transfer": "Trasferisci",
"housekeeping.room.transfer.label": "Trasferisci proprietà",
"housekeeping.room.transfer.new_owner": "ID nuovo owner",
"housekeeping.economy.select_user": "Seleziona prima un utente nel tab Utenti.",
"housekeeping.economy.target": "Target: %username% (#%id%)",
"housekeeping.economy.give_credits": "Dai crediti",
"housekeeping.economy.give_duckets": "Dai duckets",
"housekeeping.economy.give_diamonds": "Dai diamanti",
"housekeeping.economy.grant_item": "Assegna",
"housekeeping.economy.grant_item.label": "Assegna oggetto del catalogo",
"housekeeping.economy.item_id": "ID oggetto",
"housekeeping.economy.item_quantity": "Q.tà",
"housekeeping.economy.set_hc_days": "Imposta giorni HC",
"housekeeping.hotel.alert.label": "Avviso a tutto l'hotel",
"housekeeping.hotel.alert.placeholder": "Messaggio mandato a ogni utente connesso…",
"housekeeping.hotel.alert.send": "Invia a tutti",
"housekeeping.hotel.alert.confirm": "Trasmettere un avviso di %count% caratteri a ogni utente connesso?",
"housekeeping.dashboard.title": "Panoramica",
"housekeeping.dashboard.refresh": "Aggiorna",
"housekeeping.dashboard.loading": "Caricamento dashboard…",
"housekeeping.dashboard.unavailable": "Dashboard non disponibile — verifica l'endpoint admin.",
"housekeeping.dashboard.online": "Online",
"housekeeping.dashboard.total_users": "%count% totali",
"housekeeping.dashboard.rooms_active": "Stanze attive",
"housekeeping.dashboard.total_rooms": "%count% totali",
"housekeeping.dashboard.peak_today": "Picco oggi",
"housekeeping.dashboard.peak_alltime": "Picco assoluto %count%",
"housekeeping.dashboard.pending_tickets": "Ticket",
"housekeeping.dashboard.sanctions_24h": "%count% sanzioni / 24h",
"housekeeping.dashboard.server": "Server",
"housekeeping.dashboard.recent_sanctions": "Sanzioni recenti",
"housekeeping.dashboard.recent_lookups": "Ricerche recenti",
"housekeeping.audit.title": "Audit log",
"housekeeping.audit.refresh": "Aggiorna",
"housekeeping.audit.filter.all": "Tutto",
"housekeeping.audit.filter.users": "Utenti",
"housekeeping.audit.filter.rooms": "Stanze",
"housekeeping.audit.filter.hotel": "Hotel",
"housekeeping.audit.search.placeholder": "Cerca attore / target / azione…",
"housekeeping.audit.empty": "Nessuna voce di audit.",
"housekeeping.audit.no_match": "Nessuna voce corrisponde ai filtri.",
"housekeeping.field.reason": "Motivo",
"housekeeping.field.reason.placeholder": "Motivo libero (opzionale)",
"housekeeping.field.duration": "Durata",
"housekeeping.reason.default": "Nessun motivo fornito.",
"housekeeping.menu.send_to_hk": "Manda all'HK",
"housekeeping.bulk.done": "Bulk completato",
"housekeeping.bulk.success": "Tutte le azioni bulk sono riuscite.",
"housekeeping.bulk.partial": "Bulk terminato con alcuni errori.",
"housekeeping.bulk.failed": "Ogni azione bulk è fallita.",
"housekeeping.bulk.confirm": "Applicare %action% a %count% utenti selezionati?",
"housekeeping.bulk.label": "%count% selezionati",
"housekeeping.bulk.clear": "Pulisci selezione",
"housekeeping.bulk.apply": "Applica ai selezionati",
"housekeeping.telemetry.title": "Telemetria",
"housekeeping.telemetry.empty": "Nessuna azione ancora osservata.",
"housekeeping.telemetry.reset": "Resetta metriche",
"housekeeping.live.no_room": "Nessuna stanza attiva.",
"housekeeping.live.kicked": "Cacciato dalla stanza.",
"housekeeping.live.banned": "Bannato dalla stanza.",
"housekeeping.live.muted": "Mutato nella stanza.",
"housekeeping.validation.empty_username": "Il nome utente non può essere vuoto.",
"housekeeping.validation.invalid_user_id": "ID utente non valido.",
"housekeeping.validation.invalid_room_id": "ID stanza non valido.",
"housekeeping.validation.invalid_amount": "Quantità non valida.",
"housekeeping.validation.amount_too_large": "Quantità oltre il limite di sicurezza.",
"housekeeping.validation.empty_reason": "Il motivo non può essere vuoto.",
"housekeeping.validation.invalid_hours": "Durata in ore non valida.",
"housekeeping.validation.invalid_rank": "Rank non valido — deve essere tra 1 e 12."
}
@@ -0,0 +1,36 @@
export const HousekeepingActionType = {
USER_ALERT: 'user.alert',
USER_MESSAGE: 'user.message',
USER_KICK: 'user.kick',
USER_MUTE: 'user.mute',
USER_BAN: 'user.ban',
USER_TRADE_LOCK: 'user.trade_lock',
USER_CHANGE_RANK: 'user.change_rank',
USER_FORCE_DISCONNECT: 'user.force_disconnect',
USER_RESET_PASSWORD: 'user.reset_password',
USER_UNBAN: 'user.unban',
ROOM_OPEN: 'room.open',
ROOM_CLOSE: 'room.close',
ROOM_KICK_ALL: 'room.kick_all',
ROOM_TRANSFER_OWNERSHIP: 'room.transfer_ownership',
ROOM_DELETE: 'room.delete',
ROOM_MUTE: 'room.mute',
ECONOMY_GIVE_CREDITS: 'economy.give_credits',
ECONOMY_GIVE_DUCKETS: 'economy.give_duckets',
ECONOMY_GIVE_DIAMONDS: 'economy.give_diamonds',
ECONOMY_GRANT_ITEM: 'economy.grant_item',
ECONOMY_SET_HC: 'economy.set_hc',
ECONOMY_HOTEL_ALERT: 'economy.hotel_alert'
} as const;
export type HousekeepingActionType = typeof HousekeepingActionType[keyof typeof HousekeepingActionType];
export const HousekeepingTabId = {
DASHBOARD: 'dashboard',
USERS: 'users',
ROOMS: 'rooms',
ECONOMY: 'economy',
AUDIT: 'audit'
} as const;
export type HousekeepingTabId = typeof HousekeepingTabId[keyof typeof HousekeepingTabId];
+384
View File
@@ -0,0 +1,384 @@
import {
HabboSearchComposer, HabboSearchResultEvent, HousekeepingActionLogEvent, HousekeepingActionResultEvent,
HousekeepingBanUserComposer, HousekeepingDashboardEvent, HousekeepingDeleteRoomComposer,
HousekeepingFindRoomByIdComposer, HousekeepingFindUserByIdComposer, HousekeepingFindUserByNameComposer,
HousekeepingForceDisconnectUserComposer, HousekeepingGetDashboardComposer,
HousekeepingGiveCreditsComposer, HousekeepingGiveCurrencyComposer, HousekeepingGrantItemComposer,
HousekeepingKickAllFromRoomComposer, HousekeepingKickUserComposer, HousekeepingListActionLogComposer,
HousekeepingMuteRoomComposer, HousekeepingMuteUserComposer, HousekeepingResetUserPasswordComposer,
HousekeepingRoomData, HousekeepingRoomDetailEvent, HousekeepingRoomListEvent,
HousekeepingRoomStateComposer, HousekeepingSearchRoomsComposer, HousekeepingSendHotelAlertComposer,
HousekeepingSetHcSubscriptionComposer, HousekeepingSetUserRankComposer,
HousekeepingTradeLockUserComposer, HousekeepingTransferRoomOwnershipComposer,
HousekeepingUnbanUserComposer, HousekeepingUserDetailData, HousekeepingUserDetailEvent,
IMessageComposer
} from '@nitrots/nitro-renderer';
import { awaitMessageEvent } from '../nitro/awaitMessageEvent';
import { SendMessageComposer } from '../nitro/SendMessageComposer';
import {
IHousekeepingActionLogEntry, IHousekeepingActionResult, IHousekeepingDashboard,
IHousekeepingRoom, IHousekeepingRoomSummary, IHousekeepingUser, IHousekeepingUserSummary
} from './IHousekeepingTypes';
const USER_SEARCH_LIMIT = 8;
const searchUsersViaPacket = async (prefix: string, signal?: AbortSignal): Promise<IHousekeepingUserSummary[]> =>
{
SendMessageComposer(new HabboSearchComposer(prefix));
// Snapshot the parser inside the subscribe callback — the renderer
// recycles parser instances after the callback returns, so any
// post-await read of `event.getParser()` comes back null.
return await awaitMessageEvent<HabboSearchResultEvent, IHousekeepingUserSummary[]>(HabboSearchResultEvent, {
signal,
timeoutMs: 8_000,
select: event =>
{
const parser = event.getParser();
if(!parser) return [];
const combined = [ ...parser.friends, ...parser.others ];
const summaries: IHousekeepingUserSummary[] = [];
for(const entry of combined)
{
const username = entry.avatarName || '';
if(!username.toLowerCase().startsWith(prefix.toLowerCase())) continue;
summaries.push({
id: entry.avatarId,
username,
figure: entry.avatarFigure || '',
online: entry.isAvatarOnline === true,
rank: 0
});
if(summaries.length >= USER_SEARCH_LIMIT) break;
}
return summaries;
}
});
};
const mapUserDetail = (user: HousekeepingUserDetailData): IHousekeepingUser => ({
id: user.id,
username: user.username,
motto: user.motto,
figure: user.figure,
rank: user.rank,
rankName: user.rankName,
online: user.online,
lastOnlineAt: user.lastOnlineAt > 0 ? user.lastOnlineAt : null,
creditsBalance: user.creditsBalance,
ducketsBalance: user.ducketsBalance,
diamondsBalance: user.diamondsBalance,
email: user.email,
ipLast: user.ipLast,
isBanned: user.isBanned,
isMuted: user.isMuted,
isTradeLocked: user.isTradeLocked
});
const awaitUserDetail = (): Promise<IHousekeepingUser | null> =>
awaitMessageEvent<HousekeepingUserDetailEvent, IHousekeepingUser | null>(HousekeepingUserDetailEvent, {
timeoutMs: 8_000,
select: event =>
{
const parser = event.getParser();
if(!parser || !parser.found || !parser.user) return null;
return mapUserDetail(parser.user);
}
});
const findUserByNameViaPacket = async (username: string): Promise<IHousekeepingUser | null> =>
{
const trimmed = (username || '').trim();
if(!trimmed) return null;
SendMessageComposer(new HousekeepingFindUserByNameComposer(trimmed));
return awaitUserDetail();
};
const findUserByIdViaPacket = async (userId: number): Promise<IHousekeepingUser | null> =>
{
if(!Number.isFinite(userId) || userId <= 0) return null;
SendMessageComposer(new HousekeepingFindUserByIdComposer(userId));
return awaitUserDetail();
};
/**
* Fire any HK action composer and resolve when the matching
* HousekeepingActionResultEvent arrives. The server tags each ack with
* a string `actionKey` (`user.ban`, `user.mute`, …) so the listener can
* filter via the `accept` predicate — protects against another concurrent
* action's ack slipping into a waiter that was expecting a different one.
*/
const runHkAction = async (composer: IMessageComposer<unknown[]>, expectedActionKey: string, timeoutMs = 15_000): Promise<IHousekeepingActionResult> =>
{
SendMessageComposer(composer);
try
{
return await awaitMessageEvent<HousekeepingActionResultEvent, IHousekeepingActionResult>(HousekeepingActionResultEvent, {
timeoutMs,
accept: e => e.getParser()?.actionKey === expectedActionKey,
select: event =>
{
const parser = event.getParser();
if(!parser) return { ok: false, actionId: null, message: 'no_parser' };
return {
ok: parser.ok,
actionId: parser.actionId > 0 ? parser.actionId : null,
message: parser.message
};
}
});
}
catch(err)
{
const reason = err instanceof Error ? err.message : 'unknown';
return { ok: false, actionId: null, message: reason };
}
};
const banUserViaPacket = (userId: number, reason: string, hours: number): Promise<IHousekeepingActionResult> =>
runHkAction(new HousekeepingBanUserComposer(userId, reason || '', hours), 'user.ban');
const unbanUserViaPacket = (userId: number): Promise<IHousekeepingActionResult> =>
runHkAction(new HousekeepingUnbanUserComposer(userId), 'user.unban');
const muteUserViaPacket = (userId: number, reason: string, minutes: number): Promise<IHousekeepingActionResult> =>
runHkAction(new HousekeepingMuteUserComposer(userId, reason || '', minutes), 'user.mute');
const kickUserViaPacket = (userId: number, reason: string): Promise<IHousekeepingActionResult> =>
runHkAction(new HousekeepingKickUserComposer(userId, reason || ''), 'user.kick');
const forceDisconnectUserViaPacket = (userId: number, reason: string): Promise<IHousekeepingActionResult> =>
runHkAction(new HousekeepingForceDisconnectUserComposer(userId, reason || ''), 'user.disconnect');
const setUserRankViaPacket = (userId: number, rank: number): Promise<IHousekeepingActionResult> =>
runHkAction(new HousekeepingSetUserRankComposer(userId, rank), 'user.set_rank');
const tradeLockUserViaPacket = (userId: number, hours: number, reason: string): Promise<IHousekeepingActionResult> =>
runHkAction(new HousekeepingTradeLockUserComposer(userId, hours, reason || ''), 'user.trade_lock');
const resetUserPasswordViaPacket = (userId: number): Promise<IHousekeepingActionResult> =>
runHkAction(new HousekeepingResetUserPasswordComposer(userId), 'user.reset_password');
const mapRoom = (room: HousekeepingRoomData): IHousekeepingRoom => ({
id: room.id,
name: room.name,
description: room.description,
ownerId: room.ownerId,
ownerName: room.ownerName,
userCount: room.userCount,
maxUsers: room.maxUsers,
isLocked: room.isLocked,
isMuted: room.isMuted,
isPublic: room.isPublic,
createdAt: room.createdAt
});
const findRoomByIdViaPacket = (roomId: number): Promise<IHousekeepingRoom | null> =>
{
if(!Number.isFinite(roomId) || roomId <= 0) return Promise.resolve(null);
SendMessageComposer(new HousekeepingFindRoomByIdComposer(roomId));
return awaitMessageEvent<HousekeepingRoomDetailEvent, IHousekeepingRoom | null>(HousekeepingRoomDetailEvent, {
timeoutMs: 8_000,
select: event =>
{
const parser = event.getParser();
if(!parser || !parser.found || !parser.room) return null;
return mapRoom(parser.room);
}
});
};
const findRoomByNameViaPacket = (name: string): Promise<IHousekeepingRoom[]> =>
{
const trimmed = (name || '').trim();
if(!trimmed) return Promise.resolve([]);
SendMessageComposer(new HousekeepingSearchRoomsComposer(trimmed, true, 50));
return awaitMessageEvent<HousekeepingRoomListEvent, IHousekeepingRoom[]>(HousekeepingRoomListEvent, {
timeoutMs: 8_000,
select: event => event.getParser()?.rooms.map(mapRoom) ?? []
});
};
const searchRoomsViaPacket = (prefix: string, signal?: AbortSignal): Promise<IHousekeepingRoomSummary[]> =>
{
const trimmed = (prefix || '').trim();
if(!trimmed) return Promise.resolve([]);
SendMessageComposer(new HousekeepingSearchRoomsComposer(trimmed, false, 8));
return awaitMessageEvent<HousekeepingRoomListEvent, IHousekeepingRoomSummary[]>(HousekeepingRoomListEvent, {
signal,
timeoutMs: 8_000,
select: event => event.getParser()?.rooms.map(room => ({
id: room.id,
name: room.name,
userCount: room.userCount,
ownerName: room.ownerName
})) ?? []
});
};
const setRoomStateViaPacket = (roomId: number, open: boolean): Promise<IHousekeepingActionResult> =>
runHkAction(new HousekeepingRoomStateComposer(roomId, open), open ? 'room.open' : 'room.close');
const muteRoomViaPacket = (roomId: number, minutes: number): Promise<IHousekeepingActionResult> =>
runHkAction(new HousekeepingMuteRoomComposer(roomId, minutes), 'room.mute');
const kickAllFromRoomViaPacket = (roomId: number): Promise<IHousekeepingActionResult> =>
runHkAction(new HousekeepingKickAllFromRoomComposer(roomId), 'room.kick_all');
const transferRoomOwnershipViaPacket = (roomId: number, newOwnerId: number): Promise<IHousekeepingActionResult> =>
runHkAction(new HousekeepingTransferRoomOwnershipComposer(roomId, newOwnerId), 'room.transfer');
const deleteRoomViaPacket = (roomId: number): Promise<IHousekeepingActionResult> =>
runHkAction(new HousekeepingDeleteRoomComposer(roomId), 'room.delete');
const CURRENCY_DUCKETS = 0;
const CURRENCY_DIAMONDS = 5;
const giveCreditsViaPacket = (userId: number, amount: number): Promise<IHousekeepingActionResult> =>
runHkAction(new HousekeepingGiveCreditsComposer(userId, amount), 'user.give_credits');
const giveDucketsViaPacket = (userId: number, amount: number): Promise<IHousekeepingActionResult> =>
runHkAction(new HousekeepingGiveCurrencyComposer(userId, CURRENCY_DUCKETS, amount), `user.give_currency_${ CURRENCY_DUCKETS }`);
const giveDiamondsViaPacket = (userId: number, amount: number): Promise<IHousekeepingActionResult> =>
runHkAction(new HousekeepingGiveCurrencyComposer(userId, CURRENCY_DIAMONDS, amount), `user.give_currency_${ CURRENCY_DIAMONDS }`);
const grantItemViaPacket = (userId: number, itemId: number, quantity: number): Promise<IHousekeepingActionResult> =>
runHkAction(new HousekeepingGrantItemComposer(userId, itemId, quantity), 'user.grant_item');
const setHcSubscriptionViaPacket = (userId: number, days: number): Promise<IHousekeepingActionResult> =>
runHkAction(new HousekeepingSetHcSubscriptionComposer(userId, days), 'user.set_hc');
const sendHotelAlertViaPacket = (message: string): Promise<IHousekeepingActionResult> =>
runHkAction(new HousekeepingSendHotelAlertComposer(message || ''), 'hotel.alert');
const EMPTY_DASHBOARD: IHousekeepingDashboard = {
onlineUsers: 0, totalUsers: 0, activeRooms: 0, totalRooms: 0,
peakOnlineToday: 0, peakOnlineAllTime: 0, pendingTickets: 0,
sanctionsLast24h: 0, serverUptimeSeconds: 0, serverVersion: ''
};
const getDashboardViaPacket = (signal?: AbortSignal): Promise<IHousekeepingDashboard> =>
{
SendMessageComposer(new HousekeepingGetDashboardComposer());
return awaitMessageEvent<HousekeepingDashboardEvent, IHousekeepingDashboard>(HousekeepingDashboardEvent, {
signal,
timeoutMs: 10_000,
select: event =>
{
const parser = event.getParser();
if(!parser) return EMPTY_DASHBOARD;
return {
onlineUsers: parser.onlineUsers,
totalUsers: parser.totalUsers,
activeRooms: parser.activeRooms,
totalRooms: parser.totalRooms,
peakOnlineToday: parser.peakOnlineToday,
peakOnlineAllTime: parser.peakOnlineAllTime,
pendingTickets: parser.pendingTickets,
sanctionsLast24h: parser.sanctionsLast24h,
serverUptimeSeconds: parser.serverUptimeSeconds,
serverVersion: parser.serverVersion
};
}
});
};
const listActionLogViaPacket = (limit: number, signal?: AbortSignal): Promise<IHousekeepingActionLogEntry[]> =>
{
const safeLimit = Math.max(1, Math.min(500, Math.floor(limit || 50)));
SendMessageComposer(new HousekeepingListActionLogComposer(safeLimit));
return awaitMessageEvent<HousekeepingActionLogEvent, IHousekeepingActionLogEntry[]>(HousekeepingActionLogEvent, {
signal,
timeoutMs: 10_000,
select: event => event.getParser()?.entries.map(entry => ({
id: entry.id,
timestamp: entry.timestamp,
actorId: entry.actorId,
actorName: entry.actorName,
targetType: (entry.targetType === 'room' || entry.targetType === 'hotel') ? entry.targetType : 'user',
targetId: entry.targetId > 0 ? entry.targetId : null,
targetLabel: entry.targetLabel,
action: entry.action,
detail: entry.detail,
success: entry.success
})) ?? []
});
};
export const HousekeepingApi = {
// -- dashboard -------------------------------------------------
getDashboard: (signal?: AbortSignal) => getDashboardViaPacket(signal),
// -- user lookup -----------------------------------------------
findUserByName: (username: string) => findUserByNameViaPacket(username),
findUserById: (userId: number) => findUserByIdViaPacket(userId),
searchUsers: (prefix: string, signal?: AbortSignal) => searchUsersViaPacket(prefix, signal),
// -- user actions ----------------------------------------------
banUser: (userId: number, reason: string, hours: number) => banUserViaPacket(userId, reason, hours),
unbanUser: (userId: number) => unbanUserViaPacket(userId),
muteUser: (userId: number, reason: string, minutes: number) => muteUserViaPacket(userId, reason, minutes),
kickUser: (userId: number, reason: string) => kickUserViaPacket(userId, reason),
forceDisconnectUser: (userId: number, reason: string) => forceDisconnectUserViaPacket(userId, reason),
resetUserPassword: (userId: number) => resetUserPasswordViaPacket(userId),
setUserRank: (userId: number, rank: number) => setUserRankViaPacket(userId, rank),
tradeLockUser: (userId: number, hours: number, reason: string) => tradeLockUserViaPacket(userId, hours, reason),
// -- room lookup -----------------------------------------------
findRoomById: (roomId: number) => findRoomByIdViaPacket(roomId),
findRoomByName: (name: string) => findRoomByNameViaPacket(name),
searchRooms: (prefix: string, signal?: AbortSignal) => searchRoomsViaPacket(prefix, signal),
// -- room actions ----------------------------------------------
openRoom: (roomId: number) => setRoomStateViaPacket(roomId, true),
closeRoom: (roomId: number) => setRoomStateViaPacket(roomId, false),
muteRoom: (roomId: number, minutes: number) => muteRoomViaPacket(roomId, minutes),
kickAllFromRoom: (roomId: number) => kickAllFromRoomViaPacket(roomId),
transferRoomOwnership: (roomId: number, newOwnerId: number) => transferRoomOwnershipViaPacket(roomId, newOwnerId),
deleteRoom: (roomId: number) => deleteRoomViaPacket(roomId),
// -- economy actions -------------------------------------------
giveCredits: (userId: number, amount: number) => giveCreditsViaPacket(userId, amount),
giveDuckets: (userId: number, amount: number) => giveDucketsViaPacket(userId, amount),
giveDiamonds: (userId: number, amount: number) => giveDiamondsViaPacket(userId, amount),
grantItem: (userId: number, itemId: number, quantity: number) => grantItemViaPacket(userId, itemId, quantity),
setHcSubscription: (userId: number, days: number) => setHcSubscriptionViaPacket(userId, days),
// -- hotel-level -----------------------------------------------
sendHotelAlert: (message: string) => sendHotelAlertViaPacket(message),
listActionLog: (limit: number, signal?: AbortSignal) => listActionLogViaPacket(limit, signal)
} as const;
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest';
import { HousekeepingTabId } from './HousekeepingActionType';
import { housekeepingTabsForMode, isHousekeepingTabAvailable, resolveHousekeepingMode } from './HousekeepingConfig';
describe('resolveHousekeepingMode', () =>
{
it('returns "light" only for the exact "light" string', () =>
{
expect(resolveHousekeepingMode('light')).toBe('light');
});
it('falls back to "full" for any other value (unknown strings, typos, non-strings)', () =>
{
expect(resolveHousekeepingMode('full')).toBe('full');
expect(resolveHousekeepingMode('FULL')).toBe('full');
expect(resolveHousekeepingMode('Light')).toBe('full');
expect(resolveHousekeepingMode('')).toBe('full');
expect(resolveHousekeepingMode(undefined)).toBe('full');
expect(resolveHousekeepingMode(null)).toBe('full');
expect(resolveHousekeepingMode(42)).toBe('full');
expect(resolveHousekeepingMode({})).toBe('full');
});
});
describe('isHousekeepingTabAvailable', () =>
{
it('exposes every tab in full mode', () =>
{
expect(isHousekeepingTabAvailable(HousekeepingTabId.DASHBOARD, 'full')).toBe(true);
expect(isHousekeepingTabAvailable(HousekeepingTabId.USERS, 'full')).toBe(true);
expect(isHousekeepingTabAvailable(HousekeepingTabId.ROOMS, 'full')).toBe(true);
expect(isHousekeepingTabAvailable(HousekeepingTabId.ECONOMY, 'full')).toBe(true);
expect(isHousekeepingTabAvailable(HousekeepingTabId.AUDIT, 'full')).toBe(true);
});
it('exposes only Users + Rooms in light mode', () =>
{
expect(isHousekeepingTabAvailable(HousekeepingTabId.USERS, 'light')).toBe(true);
expect(isHousekeepingTabAvailable(HousekeepingTabId.ROOMS, 'light')).toBe(true);
expect(isHousekeepingTabAvailable(HousekeepingTabId.DASHBOARD, 'light')).toBe(false);
expect(isHousekeepingTabAvailable(HousekeepingTabId.ECONOMY, 'light')).toBe(false);
expect(isHousekeepingTabAvailable(HousekeepingTabId.AUDIT, 'light')).toBe(false);
});
});
describe('housekeepingTabsForMode', () =>
{
it('returns the full ordered tab list in full mode', () =>
{
expect(housekeepingTabsForMode('full')).toEqual([
HousekeepingTabId.DASHBOARD,
HousekeepingTabId.USERS,
HousekeepingTabId.ROOMS,
HousekeepingTabId.ECONOMY,
HousekeepingTabId.AUDIT
]);
});
it('returns Users + Rooms (in that order) for light mode', () =>
{
expect(housekeepingTabsForMode('light')).toEqual([
HousekeepingTabId.USERS,
HousekeepingTabId.ROOMS
]);
});
});
@@ -0,0 +1,61 @@
import { GetConfigurationValue } from '../nitro';
import { HousekeepingTabId } from './HousekeepingActionType';
export type HousekeepingMode = 'light' | 'full';
export const HOUSEKEEPING_ENABLED_KEY = 'housekeeping.enabled';
export const HOUSEKEEPING_MODE_KEY = 'housekeeping.mode';
/**
* Default-off master switch. When false, the HK module is completely
* hidden: no toolbar icon, no panel mount, no link-event routing.
* Layered ON TOP of the `acc_housekeeping` permission gate — config
* lets the operator disable HK at the build/deploy level even when
* the permission exists on the server.
*/
export const isHousekeepingEnabled = (): boolean =>
GetConfigurationValue<boolean>(HOUSEKEEPING_ENABLED_KEY, false) === true;
/**
* `full` (default) exposes the five-tab layout: dashboard, users,
* rooms, economy, audit. `light` strips the panel down to the
* essentials — Users + Rooms only — for operators who want the
* in-client HK only for live moderation, not for economy
* management. Anything else than `'light'` resolves to `'full'`
* so a typo doesn't quietly hide tabs.
*/
export const resolveHousekeepingMode = (raw: unknown): HousekeepingMode =>
(raw === 'light') ? 'light' : 'full';
export const getHousekeepingMode = (): HousekeepingMode =>
resolveHousekeepingMode(GetConfigurationValue<string>(HOUSEKEEPING_MODE_KEY, 'full'));
const LIGHT_TABS: ReadonlySet<HousekeepingTabId> = new Set<HousekeepingTabId>([
HousekeepingTabId.USERS,
HousekeepingTabId.ROOMS
]);
/**
* Pure tab-availability check. Kept side-effect-free so tab list
* filtering and toolbar / link-event gating can all read the same
* source of truth without hitting the config layer multiple times.
*/
export const isHousekeepingTabAvailable = (tab: HousekeepingTabId, mode: HousekeepingMode): boolean =>
{
if(mode === 'full') return true;
return LIGHT_TABS.has(tab);
};
export const housekeepingTabsForMode = (mode: HousekeepingMode): HousekeepingTabId[] =>
{
const all: HousekeepingTabId[] = [
HousekeepingTabId.DASHBOARD,
HousekeepingTabId.USERS,
HousekeepingTabId.ROOMS,
HousekeepingTabId.ECONOMY,
HousekeepingTabId.AUDIT
];
return all.filter(tab => isHousekeepingTabAvailable(tab, mode));
};
@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest';
import { formatCompactNumber, formatRelativePast, formatUptime } from './HousekeepingFormatters';
describe('formatUptime', () =>
{
it('renders 0/negative/NaN/Infinity as "—"', () =>
{
expect(formatUptime(-1)).toBe('—');
expect(formatUptime(NaN)).toBe('—');
expect(formatUptime(Infinity)).toBe('—');
});
it('renders seconds only for the fresh-boot case', () =>
{
expect(formatUptime(0)).toBe('0s');
expect(formatUptime(45)).toBe('45s');
});
it('renders minutes (no hour part) when below 1h', () =>
{
expect(formatUptime(60)).toBe('1m');
expect(formatUptime(60 * 59)).toBe('59m');
});
it('renders hours + minutes when below 1 day', () =>
{
expect(formatUptime(60 * 60)).toBe('1h 0m');
expect(formatUptime((60 * 60 * 5) + (60 * 12))).toBe('5h 12m');
});
it('renders days + hours + minutes when over a day', () =>
{
const fiveDaysTwelveHoursThreeMinutes = (5 * 86400) + (12 * 3600) + (3 * 60);
expect(formatUptime(fiveDaysTwelveHoursThreeMinutes)).toBe('5d 12h 3m');
});
});
describe('formatRelativePast', () =>
{
const NOW = 1_700_000_000_000; // fixed reference
it('renders "—" for invalid input', () =>
{
expect(formatRelativePast(0, NOW)).toBe('—');
expect(formatRelativePast(-100, NOW)).toBe('—');
expect(formatRelativePast(NaN, NOW)).toBe('—');
});
it('renders "now" for the first 5 seconds', () =>
{
expect(formatRelativePast(NOW - 1_000, NOW)).toBe('now');
expect(formatRelativePast(NOW - 4_000, NOW)).toBe('now');
});
it('renders seconds-ago between 5s and 1m', () =>
{
expect(formatRelativePast(NOW - 10_000, NOW)).toBe('10s ago');
expect(formatRelativePast(NOW - 59_000, NOW)).toBe('59s ago');
});
it('renders minutes / hours / days as we cross each unit boundary', () =>
{
expect(formatRelativePast(NOW - (60 * 1000), NOW)).toBe('1m ago');
expect(formatRelativePast(NOW - (3600 * 1000), NOW)).toBe('1h ago');
expect(formatRelativePast(NOW - (86_400 * 1000), NOW)).toBe('1d ago');
expect(formatRelativePast(NOW - (3 * 86_400 * 1000), NOW)).toBe('3d ago');
});
it('switches to a fixed ISO-date prefix beyond 7 days', () =>
{
const tenDaysAgoMs = NOW - (10 * 86_400 * 1000);
const expected = new Date(tenDaysAgoMs).toISOString().slice(0, 10);
expect(formatRelativePast(tenDaysAgoMs, NOW)).toBe(expected);
});
});
describe('formatCompactNumber', () =>
{
it('returns "—" for non-finite input', () =>
{
expect(formatCompactNumber(NaN)).toBe('—');
expect(formatCompactNumber(Infinity)).toBe('—');
});
it('passes through small values', () =>
{
expect(formatCompactNumber(0)).toBe('0');
expect(formatCompactNumber(42)).toBe('42');
expect(formatCompactNumber(999)).toBe('999');
});
it('uses K from 1_000 onwards (drops decimals at 10K+ for readability)', () =>
{
expect(formatCompactNumber(1_000)).toBe('1.0K');
expect(formatCompactNumber(1_500)).toBe('1.5K');
expect(formatCompactNumber(12_345)).toBe('12K');
});
it('uses M from 1_000_000 onwards (drops decimals at 10M+)', () =>
{
expect(formatCompactNumber(1_000_000)).toBe('1.0M');
expect(formatCompactNumber(2_300_000)).toBe('2.3M');
expect(formatCompactNumber(15_000_000)).toBe('15M');
});
});
@@ -0,0 +1,60 @@
const SECOND = 1;
const MINUTE = 60 * SECOND;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
/**
* "5d 12h 3m" — compact uptime display for the dashboard. We don't
* use the existing `friendlyTime` helper because that one is tuned
* for "how long ago" (past tense, single-unit), while uptime needs
* multi-unit forward-looking output and to handle seconds-only
* fresh-boot cases.
*/
export const formatUptime = (seconds: number): string =>
{
if(!Number.isFinite(seconds) || seconds < 0) return '—';
if(seconds < MINUTE) return `${ Math.floor(seconds) }s`;
const d = Math.floor(seconds / DAY);
const h = Math.floor((seconds % DAY) / HOUR);
const m = Math.floor((seconds % HOUR) / MINUTE);
if(d > 0) return `${ d }d ${ h }h ${ m }m`;
if(h > 0) return `${ h }h ${ m }m`;
return `${ m }m`;
};
/**
* "5m ago", "2h ago", "3d ago" — past-tense relative formatter for
* audit-log timestamps. Anything older than a day rolls to a fixed
* date string so the log entries stay scannable even after a week.
*/
export const formatRelativePast = (timestampMs: number, nowMs: number = Date.now()): string =>
{
if(!Number.isFinite(timestampMs) || timestampMs <= 0) return '—';
const deltaSeconds = Math.max(0, Math.floor((nowMs - timestampMs) / 1000));
if(deltaSeconds < 5) return 'now';
if(deltaSeconds < MINUTE) return `${ deltaSeconds }s ago`;
if(deltaSeconds < HOUR) return `${ Math.floor(deltaSeconds / MINUTE) }m ago`;
if(deltaSeconds < DAY) return `${ Math.floor(deltaSeconds / HOUR) }h ago`;
if(deltaSeconds < 7 * DAY) return `${ Math.floor(deltaSeconds / DAY) }d ago`;
const date = new Date(timestampMs);
return date.toISOString().slice(0, 10);
};
export const formatCompactNumber = (value: number): string =>
{
if(!Number.isFinite(value)) return '—';
const abs = Math.abs(value);
if(abs >= 1_000_000) return `${ (value / 1_000_000).toFixed(abs >= 10_000_000 ? 0 : 1) }M`;
if(abs >= 1_000) return `${ (value / 1_000).toFixed(abs >= 10_000 ? 0 : 1) }K`;
return value.toString();
};
@@ -0,0 +1,126 @@
import { getAccessToken } from '../auth/accessToken';
const trimSlash = (value: string) => value.replace(/\/$/, '');
const resolveBaseUrl = (): string =>
{
const mode = (window as any).NitroClientMode;
if(mode && typeof mode.apiBaseUrl === 'string' && mode.apiBaseUrl.length) return trimSlash(mode.apiBaseUrl);
const configured = (window as any).NitroSecureApiUrl;
if(typeof configured === 'string' && configured.length) return trimSlash(configured);
return trimSlash(window.location.origin);
};
const buildUrl = (path: string): string =>
{
const base = resolveBaseUrl();
const normalized = path.startsWith('/') ? path : `/${ path }`;
return `${ base }${ normalized }`;
};
const authHeader = (): Record<string, string> =>
{
const token = getAccessToken();
return token ? { Authorization: `Bearer ${ token }` } : {};
};
export interface HousekeepingRequestInit
{
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
body?: unknown;
query?: Record<string, string | number | boolean | undefined | null>;
signal?: AbortSignal;
}
const appendQuery = (url: string, query?: HousekeepingRequestInit['query']): string =>
{
if(!query) return url;
const params = new URLSearchParams();
for(const [ key, value ] of Object.entries(query))
{
if(value === undefined || value === null) continue;
params.set(key, String(value));
}
const qs = params.toString();
return qs.length ? `${ url }?${ qs }` : url;
};
/**
* Thin HTTP wrapper for the admin/housekeeping endpoints. Backed by the
* same `apiBaseUrl` the secure-asset layer uses, with the user's
* persisted access token attached as a bearer.
*
* Server is expected to expose REST endpoints under
* `${apiBaseUrl}/api/housekeeping/...`. The shape mirrors what
* Arcturus-style admin panels already publish, so a server-side
* implementation is incremental rather than greenfield.
*/
export const housekeepingFetch = async <T = unknown>(path: string, init: HousekeepingRequestInit = {}): Promise<T> =>
{
const { method = 'GET', body = undefined, query = undefined, signal = undefined } = init;
const url = appendQuery(buildUrl(path), query);
const response = await fetch(url, {
method,
signal,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...authHeader()
},
body: body !== undefined ? JSON.stringify(body) : undefined,
credentials: 'include'
});
if(!response.ok)
{
let detail = '';
try
{
const text = await response.text();
detail = text || '';
}
catch
{}
throw new HousekeepingHttpError(response.status, response.statusText, detail, url);
}
if(response.status === 204) return undefined;
const contentType = response.headers.get('content-type') || '';
if(!contentType.includes('application/json')) return undefined;
return await response.json();
};
export class HousekeepingHttpError extends Error
{
public readonly status: number;
public readonly statusText: string;
public readonly detail: string;
public readonly url: string;
constructor(status: number, statusText: string, detail: string, url: string)
{
super(`HK HTTP ${ status } ${ statusText }: ${ detail || url }`);
this.name = 'HousekeepingHttpError';
this.status = status;
this.statusText = statusText;
this.detail = detail;
this.url = url;
}
}
@@ -0,0 +1,120 @@
import { readFileSync, readdirSync, statSync } from 'fs';
import { join, resolve } from 'path';
import { describe, expect, it } from 'vitest';
const ROOT = resolve(__dirname, '..', '..', '..');
const EN_PATH = join(ROOT, 'public', 'configuration', 'housekeeping-texts-en.example');
const IT_PATH = join(ROOT, 'public', 'configuration', 'housekeeping-texts-it.example');
const loadDict = (path: string): Record<string, string> =>
{
const raw = readFileSync(path, 'utf8');
return JSON.parse(raw);
};
// Walk every .ts/.tsx file under src/ and extract every quoted
// `housekeeping.<...>` literal. Doesn't catch fully dynamic keys
// (e.g. `housekeeping.validation.${ k }`), so we hand-extend the
// expected set with the dynamic prefixes covered in code.
const collectReferencedKeys = (): Set<string> =>
{
const sources: string[] = [];
const walk = (dir: string) =>
{
for(const entry of readdirSync(dir))
{
if(entry.startsWith('.') || entry === 'node_modules') continue;
const full = join(dir, entry);
const stat = statSync(full);
if(stat.isDirectory()) walk(full);
else if(entry.endsWith('.ts') || entry.endsWith('.tsx')) sources.push(full);
}
};
walk(join(ROOT, 'src'));
const keys = new Set<string>();
for(const source of sources)
{
const content = readFileSync(source, 'utf8');
const matches = content.match(/['"`]housekeeping\.[a-z0-9._]+['"`]/g) || [];
for(const m of matches)
{
const cleaned = m.slice(1, -1);
// Skip config keys (they live in renderer config, not in
// the localization dict).
const CONFIG_KEYS = new Set([
'housekeeping.enabled',
'housekeeping.mode',
'housekeeping.telemetry.enabled',
'housekeeping.audit.poll_interval_ms'
]);
if(CONFIG_KEYS.has(cleaned)) continue;
keys.add(cleaned);
}
}
return keys;
};
describe('housekeeping i18n dictionaries', () =>
{
it('EN parses as valid JSON', () =>
{
expect(() => loadDict(EN_PATH)).not.toThrow();
});
it('IT parses as valid JSON', () =>
{
expect(() => loadDict(IT_PATH)).not.toThrow();
});
it('EN and IT share the exact same key set (no missing translations on either side)', () =>
{
const en = loadDict(EN_PATH);
const it = loadDict(IT_PATH);
const enKeys = new Set(Object.keys(en));
const itKeys = new Set(Object.keys(it));
const missingInIt = [ ...enKeys ].filter(k => !itKeys.has(k));
const missingInEn = [ ...itKeys ].filter(k => !enKeys.has(k));
expect(missingInIt).toEqual([]);
expect(missingInEn).toEqual([]);
});
it('every value is a non-empty string in both dicts', () =>
{
for(const path of [ EN_PATH, IT_PATH ])
{
const dict = loadDict(path);
for(const [ key, value ] of Object.entries(dict))
{
expect(typeof value).toBe('string');
expect(value.length).toBeGreaterThan(0);
expect(key.startsWith('housekeeping.')).toBe(true);
}
}
});
it('EN covers every static `housekeeping.*` key referenced in source code', () =>
{
const en = loadDict(EN_PATH);
const enKeys = new Set(Object.keys(en));
const referenced = collectReferencedKeys();
const uncovered = [ ...referenced ].filter(key => !enKeys.has(key));
expect(uncovered).toEqual([]);
});
});
@@ -0,0 +1,127 @@
import { describe, expect, it } from 'vitest';
import { emptySample, HK_METRICS_SAMPLE_CAP, recordSample, sampleToMetric } from './HousekeepingMetrics';
describe('emptySample', () =>
{
it('starts with zero samples and counts', () =>
{
const e = emptySample();
expect(e.samples).toEqual([]);
expect(e.count).toBe(0);
expect(e.errors).toBe(0);
});
});
describe('recordSample', () =>
{
it('appends one sample and increments count', () =>
{
const next = recordSample(emptySample(), 50, false);
expect(next.samples).toEqual([ 50 ]);
expect(next.count).toBe(1);
expect(next.errors).toBe(0);
});
it('tracks errors independently from total count', () =>
{
let s = emptySample();
s = recordSample(s, 10, false);
s = recordSample(s, 20, true);
s = recordSample(s, 30, false);
expect(s.count).toBe(3);
expect(s.errors).toBe(1);
});
it('never mutates the input (returns a new sample object)', () =>
{
const before = emptySample();
const after = recordSample(before, 100, false);
expect(before.samples).toEqual([]);
expect(after).not.toBe(before);
});
it('trims the sliding window to SAMPLE_CAP, keeping the most recent values', () =>
{
let s = emptySample();
// Push CAP+5 samples so the first 5 should fall off.
for(let i = 0; i < HK_METRICS_SAMPLE_CAP + 5; i++) s = recordSample(s, i, false);
expect(s.samples.length).toBe(HK_METRICS_SAMPLE_CAP);
// Most-recent sample (i = CAP+4) survives
expect(s.samples[s.samples.length - 1]).toBe(HK_METRICS_SAMPLE_CAP + 4);
// First 5 values (0..4) dropped — sample[0] now starts at 5
expect(s.samples[0]).toBe(5);
// Count keeps growing past the cap (cumulative, NOT windowed)
expect(s.count).toBe(HK_METRICS_SAMPLE_CAP + 5);
});
});
describe('sampleToMetric', () =>
{
it('returns zeros for an empty sample (no samples observed yet)', () =>
{
const m = sampleToMetric('ban', emptySample());
expect(m).toEqual({
action: 'ban',
count: 0,
errors: 0,
lastMs: 0,
minMs: 0,
maxMs: 0,
p50Ms: 0,
p95Ms: 0
});
});
it('handles a single sample (P50 == P95 == min == max == lastMs)', () =>
{
let s = emptySample();
s = recordSample(s, 42, false);
const m = sampleToMetric('kick', s);
expect(m.lastMs).toBe(42);
expect(m.minMs).toBe(42);
expect(m.maxMs).toBe(42);
expect(m.p50Ms).toBe(42);
expect(m.p95Ms).toBe(42);
});
it('computes P50 and P95 on a sorted copy (input order does not affect output)', () =>
{
// Build a known 11-sample distribution: 0..100 in steps of 10.
let s = emptySample();
const values = [ 100, 10, 50, 30, 80, 0, 70, 20, 90, 40, 60 ];
for(const v of values) s = recordSample(s, v, false);
const m = sampleToMetric('mute', s);
// With 11 samples sorted 0..100, P50 = 50 (median index 5),
// P95 = 95 (between sorted[9]=90 and sorted[10]=100, half-way).
expect(m.p50Ms).toBe(50);
expect(m.p95Ms).toBeCloseTo(95, 1);
expect(m.minMs).toBe(0);
expect(m.maxMs).toBe(100);
expect(m.lastMs).toBe(60); // last pushed value
expect(m.count).toBe(11);
});
it('preserves the error count in the snapshot', () =>
{
let s = emptySample();
s = recordSample(s, 10, true);
s = recordSample(s, 20, true);
s = recordSample(s, 30, false);
const m = sampleToMetric('ban', s);
expect(m.count).toBe(3);
expect(m.errors).toBe(2);
});
});
+109
View File
@@ -0,0 +1,109 @@
/**
* Per-action metrics — bounded sliding window of latency samples,
* P50/P95 computed on demand. Keep this pure so the action runner
* (`useHousekeepingActions.runAction`) and the debug panel render
* function can both read the same shape without re-implementing
* percentile math.
*/
export interface HousekeepingActionMetric
{
action: string;
/** Total calls observed (success + failure). */
count: number;
/** Failures only — `result.ok === false` or thrown. */
errors: number;
/** Most-recent latency in ms, plus min/max for visibility. */
lastMs: number;
minMs: number;
maxMs: number;
p50Ms: number;
p95Ms: number;
}
const SAMPLE_CAP = 50;
const percentile = (sorted: ReadonlyArray<number>, p: number): number =>
{
if(sorted.length === 0) return 0;
if(sorted.length === 1) return sorted[0];
// Linear interpolation between adjacent samples — standard
// percentile definition. Clamp the rank into [0, n-1] so p=100
// doesn't read off the end on small samples.
const rank = (p / 100) * (sorted.length - 1);
const lo = Math.floor(rank);
const hi = Math.ceil(rank);
if(lo === hi) return sorted[lo];
const frac = rank - lo;
return (sorted[lo] * (1 - frac)) + (sorted[hi] * frac);
};
export interface MetricSample
{
samples: number[];
count: number;
errors: number;
}
export const emptySample = (): MetricSample => ({ samples: [], count: 0, errors: 0 });
/**
* Append a new latency sample, trim past SAMPLE_CAP. Returns a NEW
* object so the shape plays nicely with React state updates — never
* mutates the input.
*/
export const recordSample = (current: MetricSample, latencyMs: number, isError: boolean): MetricSample =>
{
const trimmed = current.samples.length >= SAMPLE_CAP
? current.samples.slice(current.samples.length - (SAMPLE_CAP - 1))
: current.samples.slice();
trimmed.push(latencyMs);
return {
samples: trimmed,
count: current.count + 1,
errors: current.errors + (isError ? 1 : 0)
};
};
/**
* Snapshot transform — fold the sliding window into a renderable
* record. Computes percentiles on a sorted copy (small `samples`
* sizes — cap is 50, so this is essentially O(n log n) on n≤50).
*/
export const sampleToMetric = (action: string, sample: MetricSample): HousekeepingActionMetric =>
{
if(sample.samples.length === 0)
{
return {
action,
count: sample.count,
errors: sample.errors,
lastMs: 0,
minMs: 0,
maxMs: 0,
p50Ms: 0,
p95Ms: 0
};
}
const sorted = sample.samples.slice().sort((a, b) => a - b);
return {
action,
count: sample.count,
errors: sample.errors,
lastMs: sample.samples[sample.samples.length - 1],
minMs: sorted[0],
maxMs: sorted[sorted.length - 1],
p50Ms: percentile(sorted, 50),
p95Ms: percentile(sorted, 95)
};
};
export const HK_METRICS_SAMPLE_CAP = SAMPLE_CAP;
@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import { pushRecentLookup, RECENT_LOOKUPS_LIMIT, RecentLookupEntry } from './HousekeepingRecentLookups';
const entry = (over: Partial<RecentLookupEntry> = {}): RecentLookupEntry => ({
kind: 'user',
id: 1,
label: 'alice',
at: 1,
...over
});
describe('pushRecentLookup', () =>
{
it('prepends a new entry to an empty list', () =>
{
const next = pushRecentLookup([], entry({ id: 7, label: 'bob' }));
expect(next).toHaveLength(1);
expect(next[0].id).toBe(7);
});
it('moves an existing entry of the same kind+id to the front (and refreshes the timestamp)', () =>
{
const initial: RecentLookupEntry[] = [
entry({ kind: 'user', id: 1, label: 'alice', at: 1 }),
entry({ kind: 'user', id: 2, label: 'bob', at: 2 })
];
const next = pushRecentLookup(initial, entry({ kind: 'user', id: 2, label: 'bob', at: 99 }));
expect(next.map(e => e.id)).toEqual([ 2, 1 ]);
expect(next[0].at).toBe(99);
});
it('does NOT dedupe across kinds (user #1 and room #1 are distinct)', () =>
{
const next = pushRecentLookup(
[ entry({ kind: 'user', id: 1 }) ],
entry({ kind: 'room', id: 1, label: 'lobby' })
);
expect(next).toHaveLength(2);
expect(next[0].kind).toBe('room');
expect(next[1].kind).toBe('user');
});
it('trims past the limit by dropping the tail entry (caller invariant: newest at index 0, oldest at the end)', () =>
{
// Build the initial list in store-order: index 0 is the most-recently-pushed
// entry, index N-1 is the oldest. id=1 has the FRESHEST `at`, id=N has the OLDEST.
const initial: RecentLookupEntry[] = Array.from({ length: RECENT_LOOKUPS_LIMIT }, (_, i) =>
entry({ kind: 'user', id: i + 1, label: `u${ i + 1 }`, at: RECENT_LOOKUPS_LIMIT - i })
);
const tailId = initial[initial.length - 1].id;
const next = pushRecentLookup(initial, entry({ kind: 'user', id: 999, label: 'new', at: 1000 }));
expect(next).toHaveLength(RECENT_LOOKUPS_LIMIT);
expect(next[0].id).toBe(999);
// The tail entry (the oldest, by store invariant) is the one that falls off
expect(next.find(e => e.id === tailId)).toBeUndefined();
// The head of the previous list is still around, now at index 1
expect(next[1].id).toBe(initial[0].id);
});
});
@@ -0,0 +1,77 @@
const STORAGE_KEY = 'nitro.housekeeping.recent';
const MAX_ENTRIES = 8;
export interface RecentLookupEntry
{
kind: 'user' | 'room';
id: number;
label: string;
at: number;
}
const isEntry = (value: unknown): value is RecentLookupEntry =>
{
if(!value || typeof value !== 'object') return false;
const obj = value as Record<string, unknown>;
return (
(obj.kind === 'user' || obj.kind === 'room') &&
Number.isFinite(obj.id) &&
typeof obj.label === 'string' &&
Number.isFinite(obj.at)
);
};
const readStore = (): RecentLookupEntry[] =>
{
try
{
const raw = window.localStorage.getItem(STORAGE_KEY);
if(!raw) return [];
const parsed = JSON.parse(raw);
if(!Array.isArray(parsed)) return [];
return parsed.filter(isEntry);
}
catch
{
return [];
}
};
const writeStore = (entries: RecentLookupEntry[]): void =>
{
try
{
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
}
catch
{}
};
export const loadRecentLookups = (): RecentLookupEntry[] => readStore();
/**
* Push an entry to the front of the recent-lookups stack. Existing
* entries with the same kind+id are deduped (so reopening the same
* user doesn't bury fresher entries), and the list is trimmed to
* MAX_ENTRIES. Pure for the in-memory transform — the persistence is
* a side effect on top.
*/
export const pushRecentLookup = (current: RecentLookupEntry[], entry: RecentLookupEntry): RecentLookupEntry[] =>
{
const filtered = current.filter(item => !(item.kind === entry.kind && item.id === entry.id));
const next = [ entry, ...filtered ].slice(0, MAX_ENTRIES);
return next;
};
export const persistRecentLookups = (entries: RecentLookupEntry[]): void => writeStore(entries);
export const clearRecentLookups = (): void => writeStore([]);
export const RECENT_LOOKUPS_LIMIT = MAX_ENTRIES;
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest';
import {
findTemplateById, HK_SANCTION_TEMPLATES, HousekeepingSanctionType, templatesByType
} from './HousekeepingSanctionTemplates';
describe('HK_SANCTION_TEMPLATES', () =>
{
it('has a unique id for every template', () =>
{
const ids = HK_SANCTION_TEMPLATES.map(t => t.id);
const unique = new Set(ids);
expect(unique.size).toBe(ids.length);
});
it('covers every sanction type at least once', () =>
{
const types = new Set(HK_SANCTION_TEMPLATES.map(t => t.type));
expect(types.has(HousekeepingSanctionType.BAN)).toBe(true);
expect(types.has(HousekeepingSanctionType.MUTE)).toBe(true);
expect(types.has(HousekeepingSanctionType.KICK)).toBe(true);
expect(types.has(HousekeepingSanctionType.TRADE_LOCK)).toBe(true);
});
it('uses durationValue=0 for KICK templates only (kick is instant, no duration)', () =>
{
for(const template of HK_SANCTION_TEMPLATES)
{
if(template.type === HousekeepingSanctionType.KICK) expect(template.durationValue).toBe(0);
else expect(template.durationValue).toBeGreaterThan(0);
}
});
it('every template has a non-empty default reason (avoids empty-reason validation failures)', () =>
{
for(const template of HK_SANCTION_TEMPLATES)
{
expect(template.defaultReason.trim().length).toBeGreaterThan(0);
}
});
});
describe('findTemplateById', () =>
{
it('returns the matching template', () =>
{
expect(findTemplateById('ban_24h')?.type).toBe(HousekeepingSanctionType.BAN);
expect(findTemplateById('ban_24h')?.durationValue).toBe(24);
});
it('returns null for an unknown id', () =>
{
expect(findTemplateById('does-not-exist')).toBeNull();
expect(findTemplateById('')).toBeNull();
});
});
describe('templatesByType', () =>
{
it('filters the list down to a single type', () =>
{
const bans = templatesByType(HousekeepingSanctionType.BAN);
expect(bans.length).toBeGreaterThan(0);
expect(bans.every(t => t.type === HousekeepingSanctionType.BAN)).toBe(true);
});
it('returns an empty list for unknown types (defensive)', () =>
{
expect(templatesByType('unknown' as never)).toEqual([]);
});
});
@@ -0,0 +1,50 @@
export const HousekeepingSanctionType = {
BAN: 'ban',
MUTE: 'mute',
KICK: 'kick',
TRADE_LOCK: 'trade_lock'
} as const;
export type HousekeepingSanctionType = typeof HousekeepingSanctionType[keyof typeof HousekeepingSanctionType];
export interface HousekeepingSanctionTemplate
{
id: string;
/** Display name (LocalizeText key OR plain label fallback). */
name: string;
type: HousekeepingSanctionType;
/** Duration in hours for BAN / TRADE_LOCK, minutes for MUTE; ignored for KICK. */
durationValue: number;
/** Pre-canned reason — overridable from the UI textarea. */
defaultReason: string;
}
/**
* Pre-canned sanction shortcuts. Lifted from the shape of mod-tools'
* `MOD_ACTION_DEFINITIONS` (see `ModActionDefinition.ts`) but
* simplified — HK doesn't need the CFH topic / sanctionTypeId
* indirection because the HK HTTP API takes plain `(userId, reason,
* duration)` triples.
*
* Operators that need different presets can mirror this file and
* inject through the UI config layer down the road; for now keep
* a flat default set covering the common cases.
*/
export const HK_SANCTION_TEMPLATES: HousekeepingSanctionTemplate[] = [
{ id: 'kick', name: 'Kick', type: HousekeepingSanctionType.KICK, durationValue: 0, defaultReason: 'Removed from session' },
{ id: 'mute_5m', name: 'Mute 5m', type: HousekeepingSanctionType.MUTE, durationValue: 5, defaultReason: 'Cool down — chat flood' },
{ id: 'mute_60m', name: 'Mute 60m', type: HousekeepingSanctionType.MUTE, durationValue: 60, defaultReason: 'Mute — repeat offender' },
{ id: 'ban_1h', name: 'Ban 1h', type: HousekeepingSanctionType.BAN, durationValue: 1, defaultReason: 'Temporary ban — rule violation' },
{ id: 'ban_24h', name: 'Ban 24h', type: HousekeepingSanctionType.BAN, durationValue: 24, defaultReason: '24h ban — rule violation' },
{ id: 'ban_7d', name: 'Ban 7d', type: HousekeepingSanctionType.BAN, durationValue: 168, defaultReason: '7-day ban — serious violation' },
{ id: 'ban_30d', name: 'Ban 30d', type: HousekeepingSanctionType.BAN, durationValue: 720, defaultReason: '30-day ban — final warning' },
{ id: 'ban_perm', name: 'Ban permanent', type: HousekeepingSanctionType.BAN, durationValue: 24 * 365 * 100, defaultReason: 'Permanent ban' },
{ id: 'tlock_7d', name: 'Trade lock 7d', type: HousekeepingSanctionType.TRADE_LOCK, durationValue: 168, defaultReason: 'Trade lock — suspected scam' },
{ id: 'tlock_perm', name: 'Trade lock perm', type: HousekeepingSanctionType.TRADE_LOCK, durationValue: 24 * 365 * 100, defaultReason: 'Permanent trade lock' }
];
export const findTemplateById = (id: string): HousekeepingSanctionTemplate | null =>
HK_SANCTION_TEMPLATES.find(t => t.id === id) ?? null;
export const templatesByType = (type: HousekeepingSanctionType): HousekeepingSanctionTemplate[] =>
HK_SANCTION_TEMPLATES.filter(t => t.type === type);
@@ -0,0 +1,125 @@
import { describe, expect, it } from 'vitest';
import {
HK_MAX_BAN_HOURS, HK_MAX_GIVE_AMOUNT, HK_MAX_RANK, HK_MIN_RANK, HousekeepingErrorKey,
validateAmount, validateBanHours, validatePositiveId, validateRank, validateReason, validateUsername
} from './HousekeepingValidation';
describe('validateUsername', () =>
{
it('rejects empty / whitespace-only input', () =>
{
expect(validateUsername('')).toBe(HousekeepingErrorKey.EMPTY_USERNAME);
expect(validateUsername(' ')).toBe(HousekeepingErrorKey.EMPTY_USERNAME);
expect(validateUsername(null)).toBe(HousekeepingErrorKey.EMPTY_USERNAME);
});
it('accepts any non-empty trimmed value (server is source of truth for valid chars)', () =>
{
expect(validateUsername('alice')).toBe(HousekeepingErrorKey.NONE);
expect(validateUsername(' Bob ')).toBe(HousekeepingErrorKey.NONE);
});
});
describe('validatePositiveId', () =>
{
it('rejects non-positive / non-integer / NaN / non-finite', () =>
{
expect(validatePositiveId(0, 'user')).toBe(HousekeepingErrorKey.INVALID_USER_ID);
expect(validatePositiveId(-1, 'user')).toBe(HousekeepingErrorKey.INVALID_USER_ID);
expect(validatePositiveId(1.5, 'user')).toBe(HousekeepingErrorKey.INVALID_USER_ID);
expect(validatePositiveId(NaN, 'user')).toBe(HousekeepingErrorKey.INVALID_USER_ID);
expect(validatePositiveId(Infinity, 'user')).toBe(HousekeepingErrorKey.INVALID_USER_ID);
});
it('emits INVALID_ROOM_ID for the room kind', () =>
{
expect(validatePositiveId(0, 'room')).toBe(HousekeepingErrorKey.INVALID_ROOM_ID);
expect(validatePositiveId(-2, 'room')).toBe(HousekeepingErrorKey.INVALID_ROOM_ID);
});
it('accepts positive integers', () =>
{
expect(validatePositiveId(1, 'user')).toBe(HousekeepingErrorKey.NONE);
expect(validatePositiveId(99999, 'room')).toBe(HousekeepingErrorKey.NONE);
});
});
describe('validateAmount', () =>
{
it('rejects non-positive / non-integer / non-finite', () =>
{
expect(validateAmount(0)).toBe(HousekeepingErrorKey.INVALID_AMOUNT);
expect(validateAmount(-5)).toBe(HousekeepingErrorKey.INVALID_AMOUNT);
expect(validateAmount(1.5)).toBe(HousekeepingErrorKey.INVALID_AMOUNT);
expect(validateAmount(NaN)).toBe(HousekeepingErrorKey.INVALID_AMOUNT);
expect(validateAmount(Infinity)).toBe(HousekeepingErrorKey.INVALID_AMOUNT);
});
it('rejects amounts above the cap', () =>
{
expect(validateAmount(HK_MAX_GIVE_AMOUNT + 1)).toBe(HousekeepingErrorKey.AMOUNT_TOO_LARGE);
});
it('accepts the cap itself and any positive integer below it', () =>
{
expect(validateAmount(1)).toBe(HousekeepingErrorKey.NONE);
expect(validateAmount(1000)).toBe(HousekeepingErrorKey.NONE);
expect(validateAmount(HK_MAX_GIVE_AMOUNT)).toBe(HousekeepingErrorKey.NONE);
});
});
describe('validateReason', () =>
{
it('rejects empty / whitespace-only', () =>
{
expect(validateReason('')).toBe(HousekeepingErrorKey.EMPTY_REASON);
expect(validateReason(' ')).toBe(HousekeepingErrorKey.EMPTY_REASON);
expect(validateReason(null)).toBe(HousekeepingErrorKey.EMPTY_REASON);
});
it('accepts any non-empty reason', () =>
{
expect(validateReason('spam')).toBe(HousekeepingErrorKey.NONE);
});
});
describe('validateBanHours', () =>
{
it('rejects non-positive / non-finite', () =>
{
expect(validateBanHours(0)).toBe(HousekeepingErrorKey.INVALID_HOURS);
expect(validateBanHours(-1)).toBe(HousekeepingErrorKey.INVALID_HOURS);
expect(validateBanHours(NaN)).toBe(HousekeepingErrorKey.INVALID_HOURS);
expect(validateBanHours(Infinity)).toBe(HousekeepingErrorKey.INVALID_HOURS);
});
it('rejects values above the 100-year cap', () =>
{
expect(validateBanHours(HK_MAX_BAN_HOURS + 1)).toBe(HousekeepingErrorKey.INVALID_HOURS);
});
it('accepts the cap and any positive value below it (fractional included — minutes / partial hours)', () =>
{
expect(validateBanHours(1)).toBe(HousekeepingErrorKey.NONE);
expect(validateBanHours(0.5)).toBe(HousekeepingErrorKey.NONE);
expect(validateBanHours(HK_MAX_BAN_HOURS)).toBe(HousekeepingErrorKey.NONE);
});
});
describe('validateRank', () =>
{
it('rejects out-of-range values (sub-min, above-max, non-integer, non-finite)', () =>
{
expect(validateRank(HK_MIN_RANK - 1)).toBe(HousekeepingErrorKey.INVALID_RANK);
expect(validateRank(HK_MAX_RANK + 1)).toBe(HousekeepingErrorKey.INVALID_RANK);
expect(validateRank(1.5)).toBe(HousekeepingErrorKey.INVALID_RANK);
expect(validateRank(NaN)).toBe(HousekeepingErrorKey.INVALID_RANK);
});
it('accepts boundary values', () =>
{
expect(validateRank(HK_MIN_RANK)).toBe(HousekeepingErrorKey.NONE);
expect(validateRank(HK_MAX_RANK)).toBe(HousekeepingErrorKey.NONE);
expect(validateRank(5)).toBe(HousekeepingErrorKey.NONE);
});
});
@@ -0,0 +1,66 @@
export const HousekeepingErrorKey = {
NONE: 'none',
EMPTY_USERNAME: 'empty_username',
INVALID_USER_ID: 'invalid_user_id',
INVALID_ROOM_ID: 'invalid_room_id',
INVALID_AMOUNT: 'invalid_amount',
AMOUNT_TOO_LARGE: 'amount_too_large',
EMPTY_REASON: 'empty_reason',
INVALID_HOURS: 'invalid_hours',
INVALID_RANK: 'invalid_rank'
} as const;
export type HousekeepingErrorKey = typeof HousekeepingErrorKey[keyof typeof HousekeepingErrorKey];
export const HK_MAX_GIVE_AMOUNT = 1_000_000_000;
export const HK_MAX_BAN_HOURS = 24 * 365 * 100;
export const HK_MIN_RANK = 1;
export const HK_MAX_RANK = 12;
export const validateUsername = (raw: string): HousekeepingErrorKey =>
{
if(!raw || raw.trim().length === 0) return HousekeepingErrorKey.EMPTY_USERNAME;
return HousekeepingErrorKey.NONE;
};
export const validatePositiveId = (raw: number, kind: 'user' | 'room'): HousekeepingErrorKey =>
{
if(!Number.isFinite(raw) || !Number.isInteger(raw) || raw <= 0)
{
return kind === 'user' ? HousekeepingErrorKey.INVALID_USER_ID : HousekeepingErrorKey.INVALID_ROOM_ID;
}
return HousekeepingErrorKey.NONE;
};
export const validateAmount = (raw: number): HousekeepingErrorKey =>
{
if(!Number.isFinite(raw) || !Number.isInteger(raw) || raw <= 0) return HousekeepingErrorKey.INVALID_AMOUNT;
if(raw > HK_MAX_GIVE_AMOUNT) return HousekeepingErrorKey.AMOUNT_TOO_LARGE;
return HousekeepingErrorKey.NONE;
};
export const validateReason = (raw: string): HousekeepingErrorKey =>
{
if(!raw || raw.trim().length === 0) return HousekeepingErrorKey.EMPTY_REASON;
return HousekeepingErrorKey.NONE;
};
export const validateBanHours = (raw: number): HousekeepingErrorKey =>
{
if(!Number.isFinite(raw) || raw <= 0) return HousekeepingErrorKey.INVALID_HOURS;
if(raw > HK_MAX_BAN_HOURS) return HousekeepingErrorKey.INVALID_HOURS;
return HousekeepingErrorKey.NONE;
};
export const validateRank = (raw: number): HousekeepingErrorKey =>
{
if(!Number.isFinite(raw) || !Number.isInteger(raw)) return HousekeepingErrorKey.INVALID_RANK;
if(raw < HK_MIN_RANK || raw > HK_MAX_RANK) return HousekeepingErrorKey.INVALID_RANK;
return HousekeepingErrorKey.NONE;
};
@@ -0,0 +1,86 @@
export interface IHousekeepingUser
{
id: number;
username: string;
motto: string;
figure: string;
rank: number;
rankName: string;
online: boolean;
lastOnlineAt: number | null;
creditsBalance: number;
ducketsBalance: number;
diamondsBalance: number;
email: string;
ipLast: string;
isBanned: boolean;
isMuted: boolean;
isTradeLocked: boolean;
}
export interface IHousekeepingRoom
{
id: number;
name: string;
description: string;
ownerId: number;
ownerName: string;
userCount: number;
maxUsers: number;
isLocked: boolean;
isMuted: boolean;
isPublic: boolean;
createdAt: number;
}
export interface IHousekeepingActionResult
{
ok: boolean;
actionId: number | null;
message: string;
}
export interface IHousekeepingActionLogEntry
{
id: number;
timestamp: number;
actorId: number;
actorName: string;
targetType: 'user' | 'room' | 'hotel';
targetId: number | null;
targetLabel: string;
action: string;
detail: string;
success: boolean;
}
export interface IHousekeepingUserSummary
{
id: number;
username: string;
figure: string;
online: boolean;
rank: number;
}
export interface IHousekeepingRoomSummary
{
id: number;
name: string;
userCount: number;
ownerName: string;
}
export interface IHousekeepingDashboard
{
onlineUsers: number;
totalUsers: number;
activeRooms: number;
totalRooms: number;
peakOnlineToday: number;
peakOnlineAllTime: number;
pendingTickets: number;
sanctionsLast24h: number;
serverUptimeSeconds: number;
serverVersion: string;
}
+10
View File
@@ -0,0 +1,10 @@
export * from './HousekeepingActionType';
export * from './HousekeepingApi';
export * from './HousekeepingConfig';
export * from './HousekeepingFormatters';
export * from './HousekeepingHttpClient';
export * from './HousekeepingMetrics';
export * from './HousekeepingRecentLookups';
export * from './HousekeepingSanctionTemplates';
export * from './HousekeepingValidation';
export * from './IHousekeepingTypes';
+1
View File
@@ -14,6 +14,7 @@ export * from './groups';
export * from './guide-tool';
export * from './hc-center';
export * from './help';
export * from './housekeeping';
export * from './inventory';
export * from './mod-tools';
export * from './navigator';
+118
View File
@@ -0,0 +1,118 @@
import { GetCommunication, IMessageEvent } from '@nitrots/nitro-renderer';
export interface AwaitMessageEventInit<T extends IMessageEvent, R = T>
{
timeoutMs?: number;
signal?: AbortSignal;
accept?: (event: T) => boolean;
/**
* Synchronous mapper that runs INSIDE the subscribe callback, while
* the parser is still valid. Whatever it returns is what the Promise
* resolves to. **MUST** be used for any read of `event.getParser()` —
* the renderer recycles parser instances (the `_parser` field is
* nulled / repopulated for the next packet) so reading the parser
* AFTER the await microtask gives back null fields. Snapshot the
* data here, return a plain object/value, then your async code is
* safe.
*/
select?: (event: T) => R;
}
const DEFAULT_TIMEOUT_MS = 15_000;
/**
* One-shot Promise adapter over the renderer's CommunicationManager.subscribeMessage.
* Resolves on the first matching event, rejects on timeout / abort / connection error.
* Used by request-response patterns (e.g. housekeeping lookups) that need a Promise
* facade over the underlying packet stream.
*
* **Read the parser inside `select`, not after the await.** See the
* AwaitMessageEventInit.select javadoc — the renderer recycles parsers,
* so post-await reads come back null.
*/
export const awaitMessageEvent = <T extends IMessageEvent, R = T>(eventCtor: new (callback: (event: T) => void) => T, init: AwaitMessageEventInit<T, R> = {}): Promise<R> =>
{
const { timeoutMs = DEFAULT_TIMEOUT_MS, signal, accept, select } = init;
return new Promise<R>((resolve, reject) =>
{
if(signal?.aborted)
{
reject(new DOMException('aborted', 'AbortError'));
return;
}
const communication = GetCommunication();
if(!communication || !communication.connection)
{
reject(new Error('no_connection'));
return;
}
let settled = false;
let unsubscribe: (() => void) | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
let onAbort: (() => void) | null = null;
const cleanup = () =>
{
settled = true;
if(unsubscribe) unsubscribe();
unsubscribe = null;
if(timer) clearTimeout(timer);
timer = null;
if(onAbort && signal) signal.removeEventListener('abort', onAbort);
onAbort = null;
};
unsubscribe = communication.subscribeMessage(eventCtor, event =>
{
if(settled) return;
if(accept && !accept(event)) return;
// Snapshot the data synchronously: post-await reads of the
// event's parser come back null because the renderer recycles
// parser instances between packets. If no select supplied,
// resolve with the raw event for backwards-compat callers
// that don't touch the parser.
let snapshot: R;
try
{
snapshot = select ? select(event) : (event as unknown as R);
}
catch(err)
{
cleanup();
reject(err instanceof Error ? err : new Error(String(err)));
return;
}
cleanup();
resolve(snapshot);
});
timer = setTimeout(() =>
{
if(settled) return;
cleanup();
reject(new Error('timeout'));
}, timeoutMs);
if(signal)
{
onAbort = () =>
{
if(settled) return;
cleanup();
reject(new DOMException('aborted', 'AbortError'));
};
signal.addEventListener('abort', onAbort, { once: true });
}
});
};
+1
View File
@@ -1,3 +1,4 @@
export * from './awaitMessageEvent';
export * from './CreateLinkEvent';
export * from './GetConfigurationValue';
export * from './OpenUrl';
+2
View File
@@ -22,6 +22,7 @@ import { GuideToolView } from './guide-tool/GuideToolView';
import { HcCenterView } from './hc-center/HcCenterView';
import { HelpView } from './help/HelpView';
import { HotelView } from './hotel-view/HotelView';
import { HousekeepingView } from './housekeeping/HousekeepingView';
import { InventoryView } from './inventory/InventoryView';
import { ModToolsView } from './mod-tools/ModToolsView';
import { NavigatorView } from './navigator/NavigatorView';
@@ -141,6 +142,7 @@ export const MainView: FC<{}> = props =>
<TranslationBootstrap />
<GoogleAdsView />
<ModToolsView />
<HousekeepingView />
<WiredCreatorToolsView />
<RoomView />
<ChatHistoryView />
@@ -0,0 +1,63 @@
import { FC, useEffect } from 'react';
import { FaCheckCircle, FaExclamationTriangle, FaTimes } from 'react-icons/fa';
import { LocalizeText } from '../../api';
import { useHousekeepingStore } from '../../hooks';
const localizeOrPassthrough = (key: string | null): string =>
{
if(!key) return '';
if(!key.includes('.')) return key;
const localized = LocalizeText(key);
return (localized === key) ? key : localized;
};
const AUTO_DISMISS_MS = 4000;
export const HousekeepingStatusBanner: FC = () =>
{
const { lastError, lastSuccess, clearStatus, isActionPending } = useHousekeepingStore();
const visible = !!(lastError || lastSuccess);
useEffect(() =>
{
if(!lastSuccess) return;
const handle = window.setTimeout(() => clearStatus(), AUTO_DISMISS_MS);
return () => window.clearTimeout(handle);
}, [ lastSuccess, clearStatus ]);
if(!visible && !isActionPending) return null;
if(isActionPending && !visible)
{
return (
<div className="flex items-center gap-2 px-2 py-1 text-xs bg-zinc-100 border-y border-zinc-200 text-zinc-700">
<span className="inline-block h-3 w-3 rounded-full border-2 border-zinc-400 border-t-transparent animate-spin" />
<span>{ LocalizeText('housekeeping.action.pending') }</span>
</div>
);
}
const isError = !!lastError;
const message = localizeOrPassthrough(lastError ?? lastSuccess);
const tone = isError
? 'bg-rose-100 border-rose-200 text-rose-900'
: 'bg-emerald-100 border-emerald-200 text-emerald-900';
const Icon = isError ? FaExclamationTriangle : FaCheckCircle;
return (
<div className={ `flex items-center gap-2 px-2 py-1 text-xs border-y ${ tone }` } role="status">
<Icon size={ 12 } />
<span className="grow truncate">{ message }</span>
<button
className="inline-flex items-center justify-center w-5 h-5 rounded hover:bg-black/10"
onClick={ () => clearStatus() }
title={ LocalizeText('housekeeping.status.dismiss') }>
<FaTimes size={ 10 } />
</button>
</div>
);
};
@@ -0,0 +1,207 @@
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo } from 'react';
import { getHousekeepingMode, HousekeepingTabId, isHousekeepingEnabled, isHousekeepingTabAvailable, LocalizeText } from '../../api';
import { DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView, WidgetErrorBoundary } from '../../common';
import { useHasPermission, useHousekeepingStore } from '../../hooks';
import { HousekeepingStatusBanner } from './HousekeepingStatusBanner';
import { HousekeepingAuditTab } from './views/audit/HousekeepingAuditTab';
import { HousekeepingDashboardTab } from './views/dashboard/HousekeepingDashboardTab';
import { HousekeepingEconomyTab } from './views/economy/HousekeepingEconomyTab';
import { HousekeepingRoomsTab } from './views/rooms/HousekeepingRoomsTab';
import { HousekeepingUsersTab } from './views/users/HousekeepingUsersTab';
const TAB_IDS: HousekeepingTabId[] = [
HousekeepingTabId.DASHBOARD,
HousekeepingTabId.USERS,
HousekeepingTabId.ROOMS,
HousekeepingTabId.ECONOMY,
HousekeepingTabId.AUDIT
];
const isHkTabId = (value: string): value is HousekeepingTabId =>
(TAB_IDS as string[]).includes(value);
export const HousekeepingView: FC = () =>
{
const { isVisible, setIsVisible, togglePanel, activeTab, setActiveTab, closePanel, lookupUserById, seedUserFromAvatar } = useHousekeepingStore();
// Gate behind a dedicated HK permission so the panel stays hidden
// for plain users/mods on servers that haven't granted it. Reactive
// — promote/demote takes effect on the next render without a relog.
const isHk = useHasPermission('acc_housekeeping');
// Two-layer config gate on top of the permission:
// - `housekeeping.enabled` (boolean, default false): master kill
// switch for the whole module
// - `housekeeping.mode` ("light" | "full", default "full"):
// "light" exposes only Users + Rooms (essential moderation),
// "full" exposes all six tabs
// Config is read after `await GetConfiguration().init()` in
// bootstrap.ts, so by the time React mounts we're reading a
// populated value — no Suspense needed.
const hkEnabled = useMemo(() => isHousekeepingEnabled(), []);
const hkMode = useMemo(() => getHousekeepingMode(), []);
useEffect(() =>
{
const linkTracker: ILinkEventTracker = {
linkReceived: (url: string) =>
{
const parts = url.split('/');
if(parts.length < 2) return;
switch(parts[1])
{
case 'show':
setIsVisible(true);
return;
case 'hide':
closePanel();
return;
case 'toggle':
togglePanel();
return;
case 'tab':
if(parts.length > 2)
{
const candidate = parts[2];
if(isHkTabId(candidate) && isHousekeepingTabAvailable(candidate, getHousekeepingMode()))
{
setActiveTab(candidate);
setIsVisible(true);
}
}
return;
case 'user':
// housekeeping/user/<id>[/<name>/<figure>] — used by the
// in-room context menu to push a target into the HK
// panel and jump to the Users tab. When the optional
// name + figure segments are present the panel paints
// them synchronously (so the operator sees the target
// even if the find-by-id packet is slow / unhandled),
// and the background lookup enriches the rest. The
// segments are URI-encoded so usernames with spaces or
// figures with special chars survive the link round-trip.
if(parts.length > 2)
{
const userId = parseInt(parts[2]);
if(Number.isFinite(userId) && userId > 0)
{
setActiveTab(HousekeepingTabId.USERS);
setIsVisible(true);
if(parts.length > 4)
{
let name = '';
let figure = '';
try { name = decodeURIComponent(parts[3] || ''); } catch { name = parts[3] || ''; }
try { figure = decodeURIComponent(parts[4] || ''); } catch { figure = parts[4] || ''; }
seedUserFromAvatar(userId, name, figure);
}
lookupUserById(userId);
}
}
return;
}
},
eventUrlPrefix: 'housekeeping/'
};
AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker);
}, [ setIsVisible, togglePanel, closePanel, setActiveTab, lookupUserById, seedUserFromAvatar ]);
// When the panel is gated off (perm revoked mid-session, or
// `housekeeping.enabled` is false) make sure it isn't left visible.
useEffect(() =>
{
if((!isHk || !hkEnabled) && isVisible) closePanel();
}, [ isHk, hkEnabled, isVisible, closePanel ]);
// If light mode is active and the user is parked on a tab that
// light doesn't expose (e.g. they switched modes between
// sessions), bounce them to Users — the canonical default for
// the trimmed layout.
useEffect(() =>
{
if(!isHousekeepingTabAvailable(activeTab, hkMode)) setActiveTab(HousekeepingTabId.USERS);
}, [ activeTab, hkMode, setActiveTab ]);
const activeView = useMemo(() =>
{
switch(activeTab)
{
case HousekeepingTabId.ROOMS: return <HousekeepingRoomsTab />;
case HousekeepingTabId.ECONOMY: return <HousekeepingEconomyTab />;
case HousekeepingTabId.AUDIT: return <HousekeepingAuditTab />;
case HousekeepingTabId.USERS: return <HousekeepingUsersTab />;
case HousekeepingTabId.DASHBOARD:
default:
return <HousekeepingDashboardTab />;
}
}, [ activeTab ]);
if(!hkEnabled || !isHk || !isVisible) return null;
const showDashboard = isHousekeepingTabAvailable(HousekeepingTabId.DASHBOARD, hkMode);
const showEconomy = isHousekeepingTabAvailable(HousekeepingTabId.ECONOMY, hkMode);
const showAudit = isHousekeepingTabAvailable(HousekeepingTabId.AUDIT, hkMode);
const isLight = hkMode === 'light';
const headerSuffix = isLight ? ` · ${ LocalizeText('housekeeping.mode.light') }` : '';
// Light mode is narrower because there are only 2 tabs and the
// content density is lower — gives the operator more screen real
// estate without a 600px-wide panel for two tabs.
const sizeClass = isLight ? 'min-w-[420px] max-w-[480px]' : 'min-w-[520px] max-w-[600px]';
return (
<WidgetErrorBoundary name="HousekeepingView">
<NitroCardView className={ `nitro-housekeeping ${ sizeClass }` } theme="primary-slim" uniqueKey="housekeeping" windowPosition={ DraggableWindowPosition.TOP_CENTER }>
<NitroCardHeaderView headerText={ `${ LocalizeText('housekeeping.title') }${ headerSuffix }` } onCloseClick={ () => closePanel() } />
<NitroCardTabsView>
{ showDashboard &&
<NitroCardTabsItemView isActive={ activeTab === HousekeepingTabId.DASHBOARD } onClick={ () => setActiveTab(HousekeepingTabId.DASHBOARD) }>
<div className="flex items-center gap-1.5 text-xs">
<span className="nitro-icon nitro-icon-hk-tab icon-housekeeping" />
<span>{ LocalizeText('housekeeping.tab.dashboard') }</span>
</div>
</NitroCardTabsItemView> }
<NitroCardTabsItemView isActive={ activeTab === HousekeepingTabId.USERS } onClick={ () => setActiveTab(HousekeepingTabId.USERS) }>
<div className="flex items-center gap-1.5 text-xs">
<span className="nitro-icon nitro-icon-hk-tab icon-modtools" />
<span>{ LocalizeText('housekeeping.tab.users') }</span>
</div>
</NitroCardTabsItemView>
<NitroCardTabsItemView isActive={ activeTab === HousekeepingTabId.ROOMS } onClick={ () => setActiveTab(HousekeepingTabId.ROOMS) }>
<div className="flex items-center gap-1.5 text-xs">
<span className="nitro-icon nitro-icon-hk-tab icon-rooms" />
<span>{ LocalizeText('housekeeping.tab.rooms') }</span>
</div>
</NitroCardTabsItemView>
{ showEconomy &&
<NitroCardTabsItemView isActive={ activeTab === HousekeepingTabId.ECONOMY } onClick={ () => setActiveTab(HousekeepingTabId.ECONOMY) }>
<div className="flex items-center gap-1.5 text-xs">
<span className="nitro-icon nitro-icon-hk-tab icon-catalog" />
<span>{ LocalizeText('housekeeping.tab.economy') }</span>
</div>
</NitroCardTabsItemView> }
{ showAudit &&
<NitroCardTabsItemView isActive={ activeTab === HousekeepingTabId.AUDIT } onClick={ () => setActiveTab(HousekeepingTabId.AUDIT) }>
<div className="flex items-center gap-1.5 text-xs">
<span className="nitro-icon nitro-icon-hk-tab icon-message" />
<span>{ LocalizeText('housekeeping.tab.audit') }</span>
</div>
</NitroCardTabsItemView> }
</NitroCardTabsView>
<HousekeepingStatusBanner />
<NitroCardContentView className="text-black" gap={ 2 }>
{ activeView }
</NitroCardContentView>
</NitroCardView>
</WidgetErrorBoundary>
);
};
@@ -0,0 +1,224 @@
import { FC, useMemo, useState } from 'react';
import { FaCaretDown, FaCaretRight, FaCheck, FaExclamationCircle, FaFilter, FaStopwatch, FaSync, FaTrash } from 'react-icons/fa';
import { formatRelativePast, GetConfigurationValue, IHousekeepingActionLogEntry, LocalizeText, sampleToMetric } from '../../../../api';
import { Button } from '../../../../common';
import { useHousekeepingStore, useLocalStorage } from '../../../../hooks';
type TargetFilter = 'all' | 'user' | 'room' | 'hotel';
type SuccessFilter = 'all' | 'success' | 'failure';
const FILTER_LABELS: Record<TargetFilter, string> = {
all: 'housekeeping.audit.filter.all',
user: 'housekeeping.audit.filter.users',
room: 'housekeeping.audit.filter.rooms',
hotel: 'housekeeping.audit.filter.hotel'
};
const passesFilter = (entry: IHousekeepingActionLogEntry, target: TargetFilter, success: SuccessFilter, query: string): boolean =>
{
if(target !== 'all' && entry.targetType !== target) return false;
if(success === 'success' && !entry.success) return false;
if(success === 'failure' && entry.success) return false;
if(query.length > 0)
{
const haystack = `${ entry.actorName } ${ entry.targetLabel } ${ entry.action } ${ entry.detail }`.toLowerCase();
if(!haystack.includes(query)) return false;
}
return true;
};
export const HousekeepingAuditTab: FC = () =>
{
const { actionLog, refreshAuditLog, metricsByAction, resetActionMetrics } = useHousekeepingStore();
// Gated behind a UI-config flag (off by default) so non-debug
// operators don't see internal latency stats. The flag is read
// once at mount — flipping the config requires a reload, same
// as every other UI-config gate.
const telemetryEnabled = useMemo(() => GetConfigurationValue<boolean>('housekeeping.telemetry.enabled', false) === true, []);
const [ isTelemetryExpanded, setIsTelemetryExpanded ] = useState(false);
// Filter state is persisted per user so an HK who's iterating on
// "show me failures only, filtered to 'spam'" doesn't reset every
// time they switch tabs or close the panel.
const [ targetFilter, setTargetFilter ] = useLocalStorage<TargetFilter>('nitro.housekeeping.audit.target_filter', 'all');
const [ successFilter, setSuccessFilter ] = useLocalStorage<SuccessFilter>('nitro.housekeeping.audit.success_filter', 'all');
const [ query, setQuery ] = useLocalStorage<string>('nitro.housekeeping.audit.query', '');
const [ isRefreshing, setIsRefreshing ] = useState(false);
const filtered = useMemo(() =>
{
const normalizedQuery = query.trim().toLowerCase();
return actionLog.filter(entry => passesFilter(entry, targetFilter, successFilter, normalizedQuery));
}, [ actionLog, targetFilter, successFilter, query ]);
const refresh = async () =>
{
setIsRefreshing(true);
try
{
await refreshAuditLog();
}
finally
{
setIsRefreshing(false);
}
};
const successCount = useMemo(() => actionLog.filter(e => e.success).length, [ actionLog ]);
const failureCount = actionLog.length - successCount;
return (
<div className="flex flex-col gap-2">
{ /* Header w/ counts + refresh */ }
<div className="flex items-center justify-between gap-2">
<h3 className="text-xs uppercase tracking-wider font-semibold opacity-60 flex items-center gap-1">
<FaFilter size={ 10 } />
{ LocalizeText('housekeeping.audit.title') }
{ actionLog.length > 0 &&
<span className="ml-1 inline-flex items-center gap-1 text-[10px] font-normal opacity-80 normal-case">
<span className="inline-flex items-center gap-0.5 px-1 rounded bg-emerald-50 border border-emerald-200 text-emerald-700"><FaCheck size={ 6 } />{ successCount }</span>
{ failureCount > 0 &&
<span className="inline-flex items-center gap-0.5 px-1 rounded bg-rose-50 border border-rose-200 text-rose-700"><FaExclamationCircle size={ 6 } />{ failureCount }</span> }
</span> }
</h3>
<Button size="sm" variant="secondary" disabled={ isRefreshing } onClick={ refresh }>
<FaSync size={ 9 } className={ isRefreshing ? 'animate-spin' : '' } />
<span className="ml-1">{ LocalizeText('housekeeping.audit.refresh') }</span>
</Button>
</div>
{ /* Filter row */ }
<div className="flex flex-wrap gap-1 items-center rounded-md border border-zinc-200 bg-zinc-50/50 px-1.5 py-1">
{ (Object.keys(FILTER_LABELS) as TargetFilter[]).map(filter => (
<button
key={ filter }
className={ `px-2 py-0.5 rounded-full text-[10px] font-medium border transition-colors ${
targetFilter === filter
? 'bg-sky-100 border-sky-300 text-sky-800 shadow-sm'
: 'bg-white border-zinc-200 text-zinc-600 hover:border-zinc-400 hover:bg-zinc-50'
}` }
onClick={ () => setTargetFilter(filter) }>
{ LocalizeText(FILTER_LABELS[filter]) }
</button>
)) }
<span className="mx-1 h-3 w-px bg-zinc-300" />
<button
className={ `inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium border transition-colors ${
successFilter === 'success'
? 'bg-emerald-100 border-emerald-300 text-emerald-800 shadow-sm'
: 'bg-white border-zinc-200 text-zinc-600 hover:border-zinc-400'
}` }
onClick={ () => setSuccessFilter(successFilter === 'success' ? 'all' : 'success') }>
<FaCheck size={ 7 } />ok
</button>
<button
className={ `inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium border transition-colors ${
successFilter === 'failure'
? 'bg-rose-100 border-rose-300 text-rose-800 shadow-sm'
: 'bg-white border-zinc-200 text-zinc-600 hover:border-zinc-400'
}` }
onClick={ () => setSuccessFilter(successFilter === 'failure' ? 'all' : 'failure') }>
<FaExclamationCircle size={ 7 } />err
</button>
</div>
<input
className="px-2 py-1 rounded-md border border-zinc-300 bg-white text-xs focus:outline-none focus:ring-1 focus:ring-sky-300 focus:border-sky-400 transition-colors placeholder:text-zinc-400"
placeholder={ LocalizeText('housekeeping.audit.search.placeholder') }
value={ query }
onChange={ event => setQuery(event.target.value) } />
{ filtered.length === 0
? (
<div className="flex flex-col items-center gap-1 rounded-lg border border-dashed border-zinc-300 bg-zinc-50/50 py-4 text-xs text-zinc-500">
<FaFilter size={ 14 } className="opacity-40" />
<span>{ actionLog.length === 0 ? LocalizeText('housekeeping.audit.empty') : LocalizeText('housekeeping.audit.no_match') }</span>
</div>
)
: (
<ul className="flex flex-col gap-0.5 max-h-[300px] overflow-y-auto pr-1">
{ filtered.map(entry => (
<li
key={ entry.id }
className={ `flex items-center gap-2 text-[11px] px-2 py-1 rounded border transition-colors ${
entry.success ? 'border-zinc-200 bg-white hover:bg-zinc-50' : 'border-rose-200 bg-rose-50/60 hover:bg-rose-50'
}` }>
<span className="text-zinc-400 tabular-nums w-14 shrink-0">
{ formatRelativePast(entry.timestamp) }
</span>
<span className="font-semibold truncate w-24 shrink-0" title={ entry.actorName }>
{ entry.actorName }
</span>
<span className="text-zinc-400 shrink-0"></span>
<span className="truncate grow" title={ entry.targetLabel }>
<span className={ `inline-block px-1 mr-1 rounded text-[9px] uppercase font-bold ${ entry.targetType === 'user' ? 'bg-sky-100 text-sky-700' : entry.targetType === 'room' ? 'bg-violet-100 text-violet-700' : 'bg-amber-100 text-amber-700' }` }>{ entry.targetType }</span>
{ entry.targetLabel }
</span>
<span className={ `shrink-0 px-1.5 py-0.5 rounded text-[10px] font-medium ${ entry.success ? 'bg-zinc-100 text-zinc-700' : 'bg-rose-100 text-rose-700' }` }>
{ entry.action }
</span>
</li>
)) }
</ul>
) }
{ telemetryEnabled &&
<div className="rounded border border-zinc-200 bg-zinc-50">
<button
className="w-full flex items-center gap-1.5 px-2 py-1 text-[11px] uppercase font-semibold opacity-70 hover:bg-zinc-100"
onClick={ () => setIsTelemetryExpanded(value => !value) }>
{ isTelemetryExpanded ? <FaCaretDown size={ 10 } /> : <FaCaretRight size={ 10 } /> }
<FaStopwatch size={ 10 } />
<span className="grow text-left">{ LocalizeText('housekeeping.telemetry.title') }</span>
<span className="text-zinc-500 tabular-nums">{ metricsByAction.size }</span>
</button>
{ isTelemetryExpanded &&
<div className="px-2 py-1 border-t border-zinc-200">
{ metricsByAction.size === 0
? <div className="text-[10px] text-zinc-500 italic py-1">{ LocalizeText('housekeeping.telemetry.empty') }</div>
: (
<table className="w-full text-[10px] tabular-nums">
<thead>
<tr className="text-zinc-500 uppercase">
<th className="text-left font-medium">action</th>
<th className="text-right font-medium">n</th>
<th className="text-right font-medium">err</th>
<th className="text-right font-medium">last</th>
<th className="text-right font-medium">p50</th>
<th className="text-right font-medium">p95</th>
</tr>
</thead>
<tbody>
{ [ ...metricsByAction.entries() ]
.map(([ action, sample ]) => sampleToMetric(action, sample))
.sort((a, b) => b.count - a.count)
.map(metric => (
<tr key={ metric.action } className="border-t border-zinc-100">
<td className="text-left truncate" title={ metric.action }>{ metric.action }</td>
<td className="text-right">{ metric.count }</td>
<td className={ `text-right ${ metric.errors > 0 ? 'text-rose-700 font-semibold' : 'text-zinc-500' }` }>{ metric.errors }</td>
<td className="text-right">{ Math.round(metric.lastMs) }ms</td>
<td className="text-right">{ Math.round(metric.p50Ms) }ms</td>
<td className="text-right">{ Math.round(metric.p95Ms) }ms</td>
</tr>
)) }
</tbody>
</table>
) }
<div className="flex items-center justify-end pt-1">
<button
className="inline-flex items-center gap-1 text-[10px] text-zinc-500 hover:text-rose-700"
onClick={ resetActionMetrics }>
<FaTrash size={ 8 } />
{ LocalizeText('housekeeping.telemetry.reset') }
</button>
</div>
</div> }
</div> }
</div>
);
};
@@ -0,0 +1,138 @@
/* @vitest-environment jsdom */
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { HousekeepingDashboardTab } from './HousekeepingDashboardTab';
const storeState: any = {
dashboard: null,
isDashboardLoading: false,
refreshDashboard: vi.fn(),
actionLog: [],
recentLookups: [],
lookupUserById: vi.fn(),
lookupRoomById: vi.fn(),
setActiveTab: vi.fn()
};
const notificationState: any = {
simpleAlert: vi.fn()
};
vi.mock('../../../../hooks', () => ({
useHousekeepingStore: () => storeState,
useNotification: () => notificationState
}));
vi.mock('../../../../api', () =>
{
return {
LocalizeText: (key: string) => key,
formatCompactNumber: (value: number) => Number.isFinite(value) ? String(value) : '—',
formatRelativePast: () => 'now',
formatUptime: (value: number) => Number.isFinite(value) ? `${ value }s` : '—',
HousekeepingApi: {
sendHotelAlert: vi.fn(() => Promise.resolve({ ok: true, actionId: null, message: '' }))
},
HousekeepingTabId: { DASHBOARD: 'dashboard', USERS: 'users', ROOMS: 'rooms', ECONOMY: 'economy', AUDIT: 'audit' },
NotificationBubbleType: { INFO: 'INFO' }
};
});
const resetStore = () =>
{
storeState.dashboard = null;
storeState.isDashboardLoading = false;
storeState.refreshDashboard = vi.fn();
storeState.actionLog = [];
storeState.recentLookups = [];
storeState.lookupUserById = vi.fn();
storeState.lookupRoomById = vi.fn();
storeState.setActiveTab = vi.fn();
};
describe('HousekeepingDashboardTab', () =>
{
beforeEach(() => resetStore());
afterEach(() =>
{
cleanup();
vi.clearAllMocks();
});
it('renders skeleton placeholders when loading with no data yet', () =>
{
storeState.isDashboardLoading = true;
const { container } = render(<HousekeepingDashboardTab />);
expect(container.querySelectorAll('.animate-pulse').length).toBeGreaterThanOrEqual(4);
});
it('renders the unavailable banner when not loading and no data', () =>
{
render(<HousekeepingDashboardTab />);
expect(screen.getByText('housekeeping.dashboard.unavailable')).toBeTruthy();
});
it('renders the hero + stat grid when dashboard data is present', () =>
{
storeState.dashboard = {
onlineUsers: 42,
totalUsers: 1000,
activeRooms: 7,
totalRooms: 200,
peakOnlineToday: 80,
peakOnlineAllTime: 250,
pendingTickets: 3,
sanctionsLast24h: 5,
serverUptimeSeconds: 3600,
serverVersion: 'arcturus-x'
};
render(<HousekeepingDashboardTab />);
expect(screen.getByText('42')).toBeTruthy();
expect(screen.getByText('7')).toBeTruthy();
expect(screen.getByText('80')).toBeTruthy();
expect(screen.getByText('3')).toBeTruthy();
expect(screen.getByText('arcturus-x')).toBeTruthy();
});
it('renders the recent-sanctions section when audit log has successful user actions', () =>
{
storeState.dashboard = {
onlineUsers: 1, totalUsers: 1, activeRooms: 1, totalRooms: 1, peakOnlineToday: 1, peakOnlineAllTime: 1,
pendingTickets: 0, sanctionsLast24h: 0, serverUptimeSeconds: 0, serverVersion: 'x'
};
storeState.actionLog = [
{ id: 1, timestamp: 1, actorId: 1, actorName: 'admin', targetType: 'user', targetId: 2, targetLabel: 'alice', action: 'ban', detail: '', success: true },
{ id: 2, timestamp: 2, actorId: 1, actorName: 'admin', targetType: 'room', targetId: 3, targetLabel: 'room#3', action: 'close', detail: '', success: true },
{ id: 3, timestamp: 3, actorId: 1, actorName: 'admin', targetType: 'user', targetId: 4, targetLabel: 'bob', action: 'mute', detail: '', success: false }
];
render(<HousekeepingDashboardTab />);
expect(screen.getByText('housekeeping.dashboard.recent_sanctions')).toBeTruthy();
expect(screen.getByText('alice')).toBeTruthy();
expect(screen.getByText('ban')).toBeTruthy();
expect(screen.queryByText('bob')).toBeNull();
expect(screen.queryByText('room#3')).toBeNull();
});
it('renders the recent-lookups chips when there are entries', () =>
{
storeState.recentLookups = [
{ kind: 'user', id: 1, label: 'alice', at: 1 },
{ kind: 'room', id: 2, label: 'lobby', at: 2 }
];
render(<HousekeepingDashboardTab />);
expect(screen.getByText('housekeeping.dashboard.recent_lookups')).toBeTruthy();
expect(screen.getByText('alice')).toBeTruthy();
expect(screen.getByText('lobby')).toBeTruthy();
});
});
@@ -0,0 +1,319 @@
import { FC, FormEvent, ReactNode, useEffect, useMemo, useRef, useState } from 'react';
import { FaBolt, FaChartLine, FaCircle, FaCrown, FaExclamationTriangle, FaHome, FaPaperPlane, FaServer, FaSync, FaTicketAlt, FaUsers } from 'react-icons/fa';
import { formatCompactNumber, formatRelativePast, formatUptime, HousekeepingApi, HousekeepingTabId, LocalizeText, NotificationBubbleType } from '../../../../api';
import { Button } from '../../../../common';
import { useHousekeepingStore, useNotification } from '../../../../hooks';
const AUTO_REFRESH_MS = 30_000;
const STALE_AFTER_MS = 60_000;
interface StatCardProps
{
icon: ReactNode;
label: string;
value: string;
subtle?: string;
tone?: 'sky' | 'emerald' | 'amber' | 'rose' | 'violet';
onClick?: () => void;
}
const TONE_BG: Record<NonNullable<StatCardProps['tone']>, string> = {
sky: 'from-sky-50 to-transparent border-sky-200',
emerald: 'from-emerald-50 to-transparent border-emerald-200',
amber: 'from-amber-50 to-transparent border-amber-200',
rose: 'from-rose-50 to-transparent border-rose-200',
violet: 'from-violet-50 to-transparent border-violet-200'
};
const TONE_ICON: Record<NonNullable<StatCardProps['tone']>, string> = {
sky: 'text-sky-600 bg-sky-100',
emerald: 'text-emerald-600 bg-emerald-100',
amber: 'text-amber-600 bg-amber-100',
rose: 'text-rose-600 bg-rose-100',
violet: 'text-violet-600 bg-violet-100'
};
const StatCard: FC<StatCardProps> = ({ icon, label, value, subtle, tone = 'sky', onClick }) =>
{
const interactive = !!onClick;
return (
<div
onClick={ onClick }
className={ `flex items-center gap-2.5 rounded-lg p-2.5 border bg-gradient-to-br shadow-sm ${ TONE_BG[tone] } ${ interactive ? 'cursor-pointer hover:shadow-md transition-shadow' : '' }` }>
<div className={ `${ TONE_ICON[tone] } shrink-0 rounded-md p-1.5` }>{ icon }</div>
<div className="flex flex-col grow min-w-0">
<span className="text-[10px] uppercase tracking-wide opacity-60 font-semibold">{ label }</span>
<span className="text-xl font-bold leading-tight tabular-nums">{ value }</span>
{ subtle &&
<span className="text-[10px] text-zinc-500 truncate">{ subtle }</span> }
</div>
</div>
);
};
export const HousekeepingDashboardTab: FC = () =>
{
const {
dashboard,
isDashboardLoading,
refreshDashboard,
actionLog,
recentLookups,
lookupUserById,
lookupRoomById,
setActiveTab
} = useHousekeepingStore();
const { simpleAlert = null } = useNotification();
const [ alertMessage, setAlertMessage ] = useState('');
const [ isSendingAlert, setIsSendingAlert ] = useState(false);
const [ now, setNow ] = useState(() => Date.now());
const [ refreshedAt, setRefreshedAt ] = useState<number | null>(null);
// Tag every successful dashboard payload with a local timestamp.
// Triggered when the `dashboard` reference flips post-refresh.
useEffect(() =>
{
if(dashboard) setRefreshedAt(Date.now());
}, [ dashboard ]);
// Wall-clock tick — drives the "live X seconds ago" copy + the
// stale-banner trigger. Cheap (1 setState/s) and only runs while
// the tab is mounted.
useEffect(() =>
{
const id = setInterval(() => setNow(Date.now()), 1_000);
return () => clearInterval(id);
}, []);
// Auto-refresh: polls getDashboard every AUTO_REFRESH_MS while the
// tab is mounted. The HK panel's parent already kicks off an
// initial refresh on open, so this only handles the steady-state
// re-fetch; closing the tab unmounts the effect and stops the loop.
const refreshRef = useRef(refreshDashboard);
refreshRef.current = refreshDashboard;
useEffect(() =>
{
const id = setInterval(() =>
{
const ctrl = new AbortController();
refreshRef.current?.(ctrl.signal);
}, AUTO_REFRESH_MS);
return () => clearInterval(id);
}, []);
const ageMs = refreshedAt ? now - refreshedAt : null;
const isStale = ageMs !== null && ageMs > STALE_AFTER_MS;
const ageLabel = ageMs === null
? '—'
: (ageMs < 5_000 ? 'now' : `${ Math.floor(ageMs / 1000) }s ago`);
const recentSanctions = useMemo(
() => actionLog
.filter(entry => entry && entry.success && entry.targetType === 'user')
.slice(0, 5),
[ actionLog ]
);
const trimmedAlert = alertMessage.trim();
const canSendAlert = trimmedAlert.length > 0 && !isSendingAlert;
const onSubmitAlert = async (event: FormEvent) =>
{
event.preventDefault();
if(!canSendAlert) return;
setIsSendingAlert(true);
try
{
const result = await HousekeepingApi.sendHotelAlert(trimmedAlert);
if(simpleAlert)
{
if(result.ok) simpleAlert(LocalizeText('housekeeping.action.success'), NotificationBubbleType.INFO);
else simpleAlert(result.message || LocalizeText('housekeeping.action.error'), NotificationBubbleType.INFO);
}
if(result.ok) setAlertMessage('');
}
finally
{
setIsSendingAlert(false);
}
};
const onRecentClick = (entry: { kind: 'user' | 'room'; id: number; label: string }) =>
{
if(entry.kind === 'user')
{
setActiveTab(HousekeepingTabId.USERS);
lookupUserById?.(entry.id);
}
else
{
setActiveTab(HousekeepingTabId.ROOMS);
lookupRoomById?.(entry.id);
}
};
return (
<div className="flex flex-col gap-2.5">
{ /* Header row: title + live status badge + refresh */ }
<div className="flex items-center justify-between">
<h3 className="text-xs uppercase tracking-wider font-semibold opacity-60 flex items-center gap-1">
<FaChartLine size={ 10 } />
{ LocalizeText('housekeeping.dashboard.title') }
</h3>
<div className="flex items-center gap-1.5">
<span
title={ refreshedAt ? new Date(refreshedAt).toLocaleTimeString() : '' }
className={ `inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-medium border ${ isStale ? 'bg-amber-50 border-amber-200 text-amber-700' : 'bg-emerald-50 border-emerald-200 text-emerald-700' }` }>
<FaCircle size={ 6 } className={ isStale ? '' : 'animate-pulse' } />
{ isStale ? `stale · ${ ageLabel }` : `live · ${ ageLabel }` }
</span>
<Button size="sm" variant="secondary" disabled={ isDashboardLoading } onClick={ () => refreshDashboard() }>
<FaSync size={ 9 } className={ isDashboardLoading ? 'animate-spin' : '' } />
<span className="ml-1">{ LocalizeText('housekeeping.dashboard.refresh') }</span>
</Button>
</div>
</div>
{ !dashboard && isDashboardLoading &&
<div className="grid grid-cols-2 gap-1.5">
{ Array.from({ length: 4 }).map((_, i) => (
<div key={ i } className="rounded-lg border bg-zinc-50 animate-pulse h-16" />
)) }
</div> }
{ !dashboard && !isDashboardLoading &&
<div className="flex items-center gap-2 rounded border border-dashed border-zinc-300 bg-zinc-50/50 p-3 text-xs text-zinc-500 italic">
<FaExclamationTriangle size={ 12 } />
{ LocalizeText('housekeeping.dashboard.unavailable') }
</div> }
{ dashboard &&
<>
{ /* Hero card: BIG online count + pulsing dot + peak today */ }
<div className="relative overflow-hidden rounded-lg border border-emerald-200 bg-gradient-to-br from-emerald-50 via-white to-sky-50 p-3 shadow-sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="rounded-full bg-emerald-100 p-2 flex items-center justify-center">
<span className="nitro-icon nitro-icon-hk-hero icon-modtools" />
</div>
<div>
<div className="flex items-center gap-1.5 text-[10px] uppercase tracking-wider opacity-60 font-semibold">
<FaCircle size={ 6 } className="text-emerald-500 animate-pulse" />
{ LocalizeText('housekeeping.dashboard.online') }
</div>
<div className="text-3xl font-bold leading-none tabular-nums">{ formatCompactNumber(dashboard.onlineUsers) }</div>
<div className="text-[11px] text-zinc-500">
{ LocalizeText('housekeeping.dashboard.total_users', [ 'count' ], [ dashboard.totalUsers.toLocaleString() ]) }
</div>
</div>
</div>
<div className="text-right">
<div className="text-[10px] uppercase tracking-wider opacity-60 font-semibold flex items-center gap-1 justify-end">
<FaCrown size={ 9 } className="text-amber-500" />
{ LocalizeText('housekeeping.dashboard.peak_today') }
</div>
<div className="text-xl font-semibold tabular-nums">{ formatCompactNumber(dashboard.peakOnlineToday) }</div>
<div className="text-[10px] text-zinc-500">
{ LocalizeText('housekeeping.dashboard.peak_alltime', [ 'count' ], [ formatCompactNumber(dashboard.peakOnlineAllTime) ]) }
</div>
</div>
</div>
</div>
{ /* 4-card grid */ }
<div className="grid grid-cols-2 gap-1.5">
<StatCard
icon={ <FaHome size={ 14 } /> }
label={ LocalizeText('housekeeping.dashboard.rooms_active') }
value={ formatCompactNumber(dashboard.activeRooms) }
subtle={ LocalizeText('housekeeping.dashboard.total_rooms', [ 'count' ], [ dashboard.totalRooms.toLocaleString() ]) }
tone="sky"
onClick={ () => setActiveTab(HousekeepingTabId.ROOMS) } />
<StatCard
icon={ <FaTicketAlt size={ 14 } /> }
label={ LocalizeText('housekeeping.dashboard.pending_tickets') }
value={ formatCompactNumber(dashboard.pendingTickets) }
subtle={ LocalizeText('housekeeping.dashboard.sanctions_24h', [ 'count' ], [ String(dashboard.sanctionsLast24h) ]) }
tone={ dashboard.pendingTickets > 0 ? 'rose' : 'emerald' } />
<div className="col-span-2">
<StatCard
icon={ <FaServer size={ 14 } /> }
label={ LocalizeText('housekeeping.dashboard.server') }
value={ formatUptime(dashboard.serverUptimeSeconds) }
subtle={ dashboard.serverVersion }
tone="violet" />
</div>
</div>
{ /* Quick hotel-alert inline */ }
<form onSubmit={ onSubmitAlert } className="flex flex-col gap-1.5 rounded-lg border border-amber-200 bg-amber-50/40 p-2.5">
<label className="text-[10px] uppercase tracking-wider font-semibold opacity-60 flex items-center gap-1">
<FaBolt size={ 9 } className="text-amber-500" />
{ LocalizeText('housekeeping.hotel.alert.label') }
</label>
<div className="flex items-center gap-1.5">
<input
type="text"
value={ alertMessage }
onChange={ e => setAlertMessage(e.target.value) }
onKeyDown={ e => { if(e.key === 'Enter' && canSendAlert) { e.preventDefault(); onSubmitAlert(e as unknown as FormEvent); } } }
placeholder={ LocalizeText('housekeeping.hotel.alert.placeholder') }
className="grow rounded border border-amber-200 bg-white px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-amber-400 placeholder:text-zinc-400"
maxLength={ 280 } />
<Button size="sm" variant="primary" disabled={ !canSendAlert } onClick={ () => onSubmitAlert({ preventDefault: () => {} } as FormEvent) }>
<FaPaperPlane size={ 9 } className={ isSendingAlert ? 'animate-pulse' : '' } />
<span className="ml-1">{ LocalizeText('housekeeping.hotel.alert.send') }</span>
</Button>
</div>
</form>
</> }
{ /* Recent sanctions */ }
{ recentSanctions.length > 0 &&
<div className="flex flex-col gap-1">
<h4 className="text-[10px] uppercase tracking-wider font-semibold opacity-60 pt-1">
{ LocalizeText('housekeeping.dashboard.recent_sanctions') }
</h4>
<ul className="flex flex-col gap-0.5 rounded-lg border bg-white/50 divide-y divide-zinc-100">
{ recentSanctions.map(entry => (
<li key={ entry.id } className="flex items-center gap-2 text-[11px] px-2 py-1 hover:bg-zinc-50">
<span className="text-zinc-400 tabular-nums w-14 shrink-0">{ formatRelativePast(entry.timestamp) }</span>
<span className="font-semibold truncate" title={ entry.actorName }>{ entry.actorName }</span>
<span className="text-zinc-400"></span>
<span className="truncate" title={ entry.targetLabel }>{ entry.targetLabel }</span>
<span className="ml-auto px-1.5 py-0.5 rounded bg-rose-100 text-rose-700 font-medium shrink-0 text-[10px]">{ entry.action }</span>
</li>
)) }
</ul>
</div> }
{ /* Recent lookups — clickable pills that re-select the target */ }
{ recentLookups.length > 0 &&
<div className="flex flex-col gap-1">
<h4 className="text-[10px] uppercase tracking-wider font-semibold opacity-60 pt-1">
{ LocalizeText('housekeeping.dashboard.recent_lookups') }
</h4>
<div className="flex flex-wrap gap-1">
{ recentLookups.map((entry, index) => (
<button
key={ `${ entry.kind }-${ entry.id }-${ index }` }
type="button"
onClick={ () => onRecentClick(entry) }
className={ `inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] border transition-colors ${ entry.kind === 'user' ? 'bg-sky-50 border-sky-200 text-sky-700 hover:bg-sky-100' : 'bg-violet-50 border-violet-200 text-violet-700 hover:bg-violet-100' }` }
title={ `${ entry.kind } #${ entry.id }` }>
<span className="opacity-60 font-bold">{ entry.kind === 'user' ? 'U' : 'R' }</span>
<span className="font-medium">{ entry.label }</span>
</button>
)) }
</div>
</div> }
</div>
);
};
@@ -0,0 +1,174 @@
import { FC, useState } from 'react';
import { FaBullhorn, FaCrown, FaExclamationTriangle, FaGift, FaPiggyBank } from 'react-icons/fa';
import { LocalizeText } from '../../../../api';
import { Button, LayoutCurrencyIcon } from '../../../../common';
import { useHousekeeping, useHousekeepingConfirm } from '../../../../hooks';
const HOTEL_ALERT_CONFIRM_THRESHOLD = 200;
export const HousekeepingEconomyTab: FC = () =>
{
const confirm = useHousekeepingConfirm();
const {
selectedUser, isActionPending,
giveCredits, giveDuckets, giveDiamonds, grantItem, setHcSubscription, sendHotelAlert
} = useHousekeeping();
const [ creditsAmount, setCreditsAmount ] = useState<number>(1000);
const [ ducketsAmount, setDucketsAmount ] = useState<number>(100);
const [ diamondsAmount, setDiamondsAmount ] = useState<number>(10);
const [ itemId, setItemId ] = useState<number>(0);
const [ itemQuantity, setItemQuantity ] = useState<number>(1);
const [ hcDays, setHcDays ] = useState<number>(31);
const [ alertText, setAlertText ] = useState('');
const disableUserActions = !selectedUser || isActionPending;
const disableHotelActions = isActionPending;
const trimmedAlert = alertText.trim();
return (
<div className="flex flex-col gap-2.5">
{ /* Target banner */ }
{ !selectedUser
? (
<div className="flex items-center gap-2 rounded-lg border border-dashed border-amber-300 bg-amber-50/50 p-2.5 text-xs text-amber-700">
<FaExclamationTriangle size={ 12 } />
{ LocalizeText('housekeeping.economy.select_user') }
</div>
)
: (
<div className="rounded-lg border border-emerald-200 bg-gradient-to-r from-emerald-50 to-transparent px-2.5 py-1.5">
<div className="text-[10px] uppercase tracking-wider font-semibold opacity-60">
{ LocalizeText('housekeeping.economy.target', [ 'username', 'id' ], [ '', '' ]).replace(/[a-z]+:\s*/i, '').trim() || 'Target' }
</div>
<div className="text-sm font-semibold tabular-nums">{ selectedUser.username } <span className="text-zinc-400 font-normal">#{ selectedUser.id }</span></div>
</div>
) }
{ /* Currency grants — tone-coded surfaces */ }
<div className="flex flex-col gap-1.5">
{ /* Credits — amber */ }
<div className="flex items-center gap-1.5 rounded-md border border-amber-200 bg-amber-50/40 px-2 py-1.5">
<LayoutCurrencyIcon type={ -1 } classNames={ [ 'shrink-0' ] } />
<input
type="number"
min={ 1 }
className="w-24 px-1.5 py-0.5 rounded border border-amber-200 bg-white text-xs tabular-nums focus:outline-none focus:ring-1 focus:ring-amber-400"
value={ creditsAmount }
onChange={ event => setCreditsAmount(parseInt(event.target.value) || 0) } />
<Button variant="success" disabled={ disableUserActions } className="grow ml-auto" onClick={ () => giveCredits(selectedUser.id, creditsAmount) }>
<FaPiggyBank size={ 10 } />
<span className="ml-1">{ LocalizeText('housekeeping.economy.give_credits') }</span>
</Button>
</div>
{ /* Duckets — orange */ }
<div className="flex items-center gap-1.5 rounded-md border border-orange-200 bg-orange-50/40 px-2 py-1.5">
<LayoutCurrencyIcon type={ 0 } classNames={ [ 'shrink-0' ] } />
<input
type="number"
min={ 1 }
className="w-24 px-1.5 py-0.5 rounded border border-orange-200 bg-white text-xs tabular-nums focus:outline-none focus:ring-1 focus:ring-orange-400"
value={ ducketsAmount }
onChange={ event => setDucketsAmount(parseInt(event.target.value) || 0) } />
<Button variant="success" disabled={ disableUserActions } className="grow ml-auto" onClick={ () => giveDuckets(selectedUser.id, ducketsAmount) }>
<FaPiggyBank size={ 10 } />
<span className="ml-1">{ LocalizeText('housekeeping.economy.give_duckets') }</span>
</Button>
</div>
{ /* Diamonds — sky */ }
<div className="flex items-center gap-1.5 rounded-md border border-sky-200 bg-sky-50/40 px-2 py-1.5">
<LayoutCurrencyIcon type={ 5 } classNames={ [ 'shrink-0' ] } />
<input
type="number"
min={ 1 }
className="w-24 px-1.5 py-0.5 rounded border border-sky-200 bg-white text-xs tabular-nums focus:outline-none focus:ring-1 focus:ring-sky-400"
value={ diamondsAmount }
onChange={ event => setDiamondsAmount(parseInt(event.target.value) || 0) } />
<Button variant="success" disabled={ disableUserActions } className="grow ml-auto" onClick={ () => giveDiamonds(selectedUser.id, diamondsAmount) }>
<FaPiggyBank size={ 10 } />
<span className="ml-1">{ LocalizeText('housekeeping.economy.give_diamonds') }</span>
</Button>
</div>
</div>
{ /* Grant item card */ }
<div className="flex flex-col gap-1.5 rounded-md border border-violet-200 bg-violet-50/40 p-2">
<label className="text-[10px] uppercase tracking-wider font-semibold opacity-60 flex items-center gap-1">
<FaGift size={ 8 } className="text-violet-500" />
{ LocalizeText('housekeeping.economy.grant_item.label') }
</label>
<div className="flex items-center gap-1.5">
<input
type="number"
min={ 1 }
className="w-24 px-1.5 py-1 rounded border border-violet-200 bg-white text-xs tabular-nums focus:outline-none focus:ring-1 focus:ring-violet-400"
placeholder={ LocalizeText('housekeeping.economy.item_id') }
value={ itemId || '' }
onChange={ event => setItemId(parseInt(event.target.value) || 0) } />
<input
type="number"
min={ 1 }
className="w-16 px-1.5 py-1 rounded border border-violet-200 bg-white text-xs tabular-nums focus:outline-none focus:ring-1 focus:ring-violet-400"
placeholder={ LocalizeText('housekeeping.economy.item_quantity') }
value={ itemQuantity }
onChange={ event => setItemQuantity(parseInt(event.target.value) || 0) } />
<Button variant="primary" disabled={ disableUserActions || !itemId } className="grow" onClick={ () => grantItem(selectedUser.id, itemId, itemQuantity) }>
<FaGift size={ 10 } />
<span className="ml-1">{ LocalizeText('housekeeping.economy.grant_item') }</span>
</Button>
</div>
</div>
{ /* HC subscription */ }
<div className="flex items-center gap-1.5 rounded-md border border-amber-200 bg-gradient-to-r from-amber-50 to-yellow-50 px-2 py-1.5">
<FaCrown size={ 13 } className="text-amber-600 shrink-0" />
<input
type="number"
min={ 0 }
className="w-20 px-1.5 py-0.5 rounded border border-amber-200 bg-white text-xs tabular-nums focus:outline-none focus:ring-1 focus:ring-amber-400"
value={ hcDays }
onChange={ event => setHcDays(parseInt(event.target.value) || 0) } />
<span className="text-[11px] text-zinc-600">days</span>
<Button variant="warning" disabled={ disableUserActions } className="grow ml-auto" onClick={ () => setHcSubscription(selectedUser.id, hcDays) }>
<FaCrown size={ 10 } />
<span className="ml-1">{ LocalizeText('housekeeping.economy.set_hc_days') }</span>
</Button>
</div>
{ /* Hotel-wide alert */ }
<div className="flex flex-col gap-1.5 rounded-md border border-rose-200 bg-rose-50/40 p-2">
<label className="text-[10px] uppercase tracking-wider font-semibold opacity-60 flex items-center gap-1">
<FaBullhorn size={ 9 } className="text-rose-500" />
{ LocalizeText('housekeeping.hotel.alert.label') }
</label>
<textarea
className="min-h-[60px] px-2 py-1 rounded text-sm border border-rose-200 bg-white focus:outline-none focus:ring-1 focus:ring-rose-400 placeholder:text-zinc-400"
placeholder={ LocalizeText('housekeeping.hotel.alert.placeholder') }
value={ alertText }
onChange={ event => setAlertText(event.target.value) } />
<div className="flex items-center justify-between">
<span className="text-[10px] text-zinc-500 tabular-nums">{ trimmedAlert.length } chars</span>
<Button variant="danger" disabled={ disableHotelActions || !trimmedAlert.length } onClick={ () =>
{
const dispatch = () => sendHotelAlert(trimmedAlert);
// Lungo alert hotel-wide → conferma esplicita.
if(trimmedAlert.length >= HOTEL_ALERT_CONFIRM_THRESHOLD)
{
confirm(LocalizeText('housekeeping.hotel.alert.confirm', [ 'count' ], [ String(trimmedAlert.length) ]), dispatch);
return;
}
dispatch();
} }>
<FaBullhorn size={ 10 } />
<span className="ml-1">{ LocalizeText('housekeeping.hotel.alert.send') }</span>
</Button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,198 @@
import { FC, useState } from 'react';
import { FaCrown, FaDoorOpen, FaExchangeAlt, FaHome, FaLock, FaMapMarkerAlt, FaSearch, FaTimes, FaTrash, FaUserSlash, FaUsers, FaVolumeMute } from 'react-icons/fa';
import { LocalizeText } from '../../../../api';
import { Button } from '../../../../common';
import { useHousekeeping, useHousekeepingConfirm, useRoom } from '../../../../hooks';
const DEFAULT_MUTE_MINUTES = 10;
export const HousekeepingRoomsTab: FC = () =>
{
const {
selectedRoom, setSelectedRoom, lookupRoomById, isRoomLoading, isActionPending,
openRoom, closeRoom, muteRoom, kickAllFromRoom, transferRoomOwnership, deleteRoom
} = useHousekeeping();
const { roomSession = null } = useRoom();
const [ query, setQuery ] = useState('');
const [ muteMinutes, setMuteMinutes ] = useState<number>(DEFAULT_MUTE_MINUTES);
const [ newOwnerId, setNewOwnerId ] = useState<number>(0);
const confirm = useHousekeepingConfirm();
const currentRoomId = roomSession && roomSession.roomId > 0 ? roomSession.roomId : 0;
// Empty query + Cerca → fall back to the room the operator is
// currently standing in. Saves a copy-paste of the room id from
// navigator just to inspect "this room". Mirrors how /ban / /kick
// in chat default to the active room.
const submitLookup = () =>
{
const trimmed = query.trim();
const idFromQuery = parseInt(trimmed);
const id = (Number.isFinite(idFromQuery) && idFromQuery > 0) ? idFromQuery : currentRoomId;
if(id <= 0) return;
lookupRoomById(id);
};
const useCurrentRoom = () =>
{
if(currentRoomId <= 0) return;
setQuery(String(currentRoomId));
lookupRoomById(currentRoomId);
};
const disableActions = !selectedRoom || isActionPending;
const confirmAndRun = (key: string, fn: () => void) => confirm(LocalizeText(key), fn);
const occupancyPct = selectedRoom && selectedRoom.maxUsers > 0
? Math.min(100, Math.round((selectedRoom.userCount / selectedRoom.maxUsers) * 100))
: 0;
return (
<div className="flex flex-col gap-2">
{ /* Lookup bar */ }
<div className="flex gap-1.5 items-center">
<div className="flex items-center gap-1 grow rounded-md border border-zinc-300 bg-white px-2 py-1 shadow-sm focus-within:ring-1 focus-within:ring-sky-300 focus-within:border-sky-400 transition-colors">
<FaSearch className="text-zinc-400 shrink-0" size={ 11 } />
<input
type="number"
min={ 1 }
className="grow text-sm bg-transparent outline-none placeholder:text-zinc-400"
placeholder={ currentRoomId > 0
? `${ LocalizeText('housekeeping.room.search.placeholder') } · empty → current #${ currentRoomId }`
: LocalizeText('housekeeping.room.search.placeholder') }
value={ query }
onChange={ event => setQuery(event.target.value) }
onKeyDown={ event => { if(event.key === 'Enter') submitLookup(); } } />
</div>
{ currentRoomId > 0 && currentRoomId !== selectedRoom?.id &&
<Button
gap={ 1 }
variant="secondary"
disabled={ isRoomLoading }
title={ `Lookup current room #${ currentRoomId }` }
onClick={ useCurrentRoom }>
<FaMapMarkerAlt size={ 10 } className="text-sky-500" />
<span>here</span>
</Button> }
<Button gap={ 1 } disabled={ isRoomLoading } onClick={ submitLookup }>
<FaSearch size={ 10 } className={ isRoomLoading ? 'animate-pulse' : '' } />
<span>{ LocalizeText('housekeeping.room.search.button') }</span>
</Button>
</div>
{ /* Selected room hero card */ }
{ selectedRoom
? (
<div className="relative overflow-hidden rounded-lg border border-sky-200 bg-gradient-to-br from-sky-50 via-white to-violet-50 p-3 shadow-sm">
<div className="flex items-start gap-3">
<div className="rounded-full bg-sky-100 p-2 shrink-0 flex items-center justify-center">
<span className="nitro-icon nitro-icon-hk-hero icon-rooms" />
</div>
<div className="grow min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="font-bold text-base truncate">{ selectedRoom.name }</span>
<span className="text-[10px] text-zinc-500 tabular-nums">#{ selectedRoom.id }</span>
{ selectedRoom.isPublic &&
<span className="text-[9px] uppercase font-semibold px-1.5 py-0.5 rounded-full bg-emerald-100 border border-emerald-200 text-emerald-800">public</span> }
{ selectedRoom.isLocked &&
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-semibold bg-rose-100 border border-rose-200 text-rose-700"><FaLock size={ 8 } /> closed</span> }
{ selectedRoom.isMuted &&
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-semibold bg-amber-100 border border-amber-200 text-amber-700"><FaVolumeMute size={ 8 } /> muted</span> }
</div>
<div className="text-xs text-zinc-600 truncate mt-0.5">{ selectedRoom.description || '—' }</div>
<div className="flex items-center gap-3 text-[11px] text-zinc-700 mt-1.5">
<span className="inline-flex items-center gap-1" title={ `${ selectedRoom.userCount } / ${ selectedRoom.maxUsers }` }>
<FaUsers size={ 10 } className="text-sky-600" />
<span className="tabular-nums font-semibold">{ selectedRoom.userCount }</span>
<span className="text-zinc-400">/</span>
<span className="tabular-nums">{ selectedRoom.maxUsers }</span>
</span>
<span className="inline-flex items-center gap-1 truncate" title={ selectedRoom.ownerName }>
<FaCrown size={ 10 } className="text-amber-500" />
<span className="truncate">{ selectedRoom.ownerName }</span>
<span className="text-zinc-400 tabular-nums">#{ selectedRoom.ownerId }</span>
</span>
</div>
{ selectedRoom.maxUsers > 0 &&
<div className="h-1 mt-1.5 rounded-full bg-zinc-100 overflow-hidden">
<div
className={ `h-full transition-all ${ occupancyPct > 85 ? 'bg-rose-500' : occupancyPct > 60 ? 'bg-amber-500' : 'bg-emerald-500' }` }
style={ { width: `${ occupancyPct }%` } } />
</div> }
</div>
<button
className="text-zinc-400 hover:text-rose-600 transition-colors p-1"
onClick={ () => setSelectedRoom(null) }
title={ LocalizeText('housekeeping.room.clear') }>
<FaTimes size={ 12 } />
</button>
</div>
</div>
)
: (
<div className="flex items-center gap-2 rounded-lg border border-dashed border-zinc-300 bg-zinc-50/50 p-3 text-xs text-zinc-500 italic">
<FaHome size={ 14 } />
{ LocalizeText('housekeeping.room.none') }
</div>
) }
{ /* Open / Close + Mute */ }
<div className="grid grid-cols-2 gap-1.5">
<Button variant="success" disabled={ disableActions || !selectedRoom?.isLocked } onClick={ () => openRoom(selectedRoom.id) }>
<FaDoorOpen size={ 10 } />
<span className="ml-1">{ LocalizeText('housekeeping.room.open') }</span>
</Button>
<Button variant="danger" disabled={ disableActions || selectedRoom?.isLocked } onClick={ () => closeRoom(selectedRoom.id) }>
<FaLock size={ 10 } />
<span className="ml-1">{ LocalizeText('housekeeping.room.close') }</span>
</Button>
<div className="col-span-2 flex items-center gap-1.5 rounded-md border border-amber-200 bg-amber-50/40 px-2 py-1.5">
<FaVolumeMute size={ 11 } className="text-amber-600" />
<input
type="number"
min={ 1 }
className="w-14 px-1.5 py-0.5 rounded border border-amber-200 bg-white text-xs tabular-nums focus:outline-none focus:ring-1 focus:ring-amber-400"
value={ muteMinutes }
onChange={ event => setMuteMinutes(parseInt(event.target.value) || 0) } />
<span className="text-[11px] text-zinc-600">min</span>
<Button variant="warning" disabled={ disableActions } className="ml-auto" onClick={ () => muteRoom(selectedRoom.id, muteMinutes) }>
<span>{ LocalizeText('housekeeping.room.mute_min', [ 'm' ], [ String(muteMinutes) ]) }</span>
</Button>
</div>
<Button variant="warning" disabled={ disableActions } onClick={ () => confirmAndRun('housekeeping.room.kick_all.confirm', () => kickAllFromRoom(selectedRoom.id)) }>
<FaUserSlash size={ 10 } />
<span className="ml-1">{ LocalizeText('housekeeping.room.kick_all') }</span>
</Button>
<Button variant="danger" disabled={ disableActions } onClick={ () => confirmAndRun('housekeeping.room.delete.confirm', () => deleteRoom(selectedRoom.id)) }>
<FaTrash size={ 10 } />
<span className="ml-1">{ LocalizeText('housekeeping.room.delete') }</span>
</Button>
</div>
{ /* Transfer ownership card */ }
<div className="flex flex-col gap-1.5 rounded-md border border-violet-200 bg-violet-50/40 p-2">
<label className="text-[10px] uppercase tracking-wider font-semibold opacity-60 flex items-center gap-1">
<FaExchangeAlt size={ 8 } className="text-violet-500" />
{ LocalizeText('housekeeping.room.transfer.label') }
</label>
<div className="flex items-center gap-1.5">
<input
type="number"
min={ 1 }
className="w-24 px-1.5 py-1 rounded border border-violet-200 bg-white text-xs tabular-nums focus:outline-none focus:ring-1 focus:ring-violet-400"
placeholder={ LocalizeText('housekeeping.room.transfer.new_owner') }
value={ newOwnerId || '' }
onChange={ event => setNewOwnerId(parseInt(event.target.value) || 0) } />
<Button variant="primary" disabled={ disableActions || !newOwnerId } className="grow" onClick={ () => transferRoomOwnership(selectedRoom.id, newOwnerId) }>
<FaExchangeAlt size={ 10 } />
<span className="ml-1">{ LocalizeText('housekeeping.room.transfer') }</span>
</Button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,439 @@
import { FC, useEffect, useRef, useState } from 'react';
import { FaBan, FaBolt, FaCircle, FaCoins, FaEnvelope, FaExclamationTriangle, FaIdBadge, FaKey, FaLock, FaPlug, FaSearch, FaTimes, FaUser, FaUserShield, FaUserSlash, FaVolumeMute } from 'react-icons/fa';
import { findTemplateById, HK_SANCTION_TEMPLATES, HousekeepingSanctionType, LocalizeText } from '../../../../api';
import { Button, LayoutAvatarImageView, LayoutCurrencyIcon } from '../../../../common';
import { useHousekeeping, useHousekeepingConfirm } from '../../../../hooks';
const DEFAULT_BAN_HOURS = 18;
const DEFAULT_MUTE_MINUTES = 60;
const DEFAULT_TRADE_LOCK_HOURS = 168;
const BULK_CONFIRM_THRESHOLD = 5;
export const HousekeepingUsersTab: FC = () =>
{
const {
selectedUser, setSelectedUser, lookupUserByName, lookupUserById, isUserLoading, isActionPending,
banUser, unbanUser, kickUser, muteUser, forceDisconnectUser, resetUserPassword, setUserRank, tradeLockUser,
userSuggestions, requestUserSuggestions, recentLookups,
kickFromCurrentRoom, banFromCurrentRoom, muteInCurrentRoom,
selectedUserIds, toggleUserSelection, clearUserSelection,
banUsersBulk, kickUsersBulk, muteUsersBulk
} = useHousekeeping();
const confirm = useHousekeepingConfirm();
const [ query, setQuery ] = useState('');
const [ isFocused, setIsFocused ] = useState(false);
const [ reason, setReason ] = useState('');
const [ banHours, setBanHours ] = useState<number>(DEFAULT_BAN_HOURS);
const [ muteMinutes, setMuteMinutes ] = useState<number>(DEFAULT_MUTE_MINUTES);
const [ tradeLockHours, setTradeLockHours ] = useState<number>(DEFAULT_TRADE_LOCK_HOURS);
const [ rankDraft, setRankDraft ] = useState<number>(1);
const [ templateId, setTemplateId ] = useState<string>('');
const blurTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () =>
{
if(blurTimerRef.current) clearTimeout(blurTimerRef.current);
}, []);
useEffect(() =>
{
requestUserSuggestions(query);
}, [ query, requestUserSuggestions ]);
const submitLookup = () =>
{
const trimmed = query.trim();
if(!trimmed.length) return;
lookupUserByName(trimmed);
setIsFocused(false);
};
const recentUsers = recentLookups.filter(entry => entry.kind === 'user').slice(0, 5);
const showSuggestionPanel = isFocused && (userSuggestions.length > 0 || (recentUsers.length > 0 && query.trim().length < 2));
const disableActions = !selectedUser || isActionPending;
const reasonOrDefault = reason.trim().length ? reason.trim() : LocalizeText('housekeeping.reason.default');
const applyTemplate = (id: string) =>
{
setTemplateId(id);
const template = findTemplateById(id);
if(!template) return;
// Pre-fill the duration inputs the template targets, and seed
// the reason textarea — operator can still tweak before
// hitting the per-action button.
setReason(template.defaultReason);
if(template.type === HousekeepingSanctionType.BAN) setBanHours(template.durationValue);
if(template.type === HousekeepingSanctionType.MUTE) setMuteMinutes(template.durationValue);
if(template.type === HousekeepingSanctionType.TRADE_LOCK) setTradeLockHours(template.durationValue);
};
const runBulkWithGate = (kind: 'ban' | 'kick' | 'mute', actionLabel: string, runner: () => void) =>
{
if(selectedUserIds.length === 0) return;
// Big bulks need a confirm — a stray click can sanction
// dozens of users in one shot otherwise.
if(selectedUserIds.length >= BULK_CONFIRM_THRESHOLD)
{
confirm(
LocalizeText('housekeeping.bulk.confirm', [ 'action', 'count' ], [ actionLabel, String(selectedUserIds.length) ]),
runner
);
return;
}
runner();
};
const bulkBan = () => runBulkWithGate('ban', LocalizeText('housekeeping.action.ban_h', [ 'h' ], [ String(banHours) ]),
() => banUsersBulk(selectedUserIds, reasonOrDefault, banHours));
const bulkKick = () => runBulkWithGate('kick', LocalizeText('housekeeping.action.kick'),
() => kickUsersBulk(selectedUserIds, reasonOrDefault));
const bulkMute = () => runBulkWithGate('mute', LocalizeText('housekeeping.action.mute_min', [ 'm' ], [ String(muteMinutes) ]),
() => muteUsersBulk(selectedUserIds, reasonOrDefault, muteMinutes));
return (
<div className="flex flex-col gap-2">
{/* Lookup with autocomplete */}
<div className="relative">
<div className="flex gap-1.5 items-center">
<div className="flex items-center gap-1 grow rounded border border-zinc-300 bg-white px-2 py-1">
<FaSearch className="text-zinc-400 shrink-0" size={ 11 } />
<input
className="grow text-sm bg-transparent outline-none"
placeholder={ LocalizeText('housekeeping.user.search.placeholder') }
value={ query }
onChange={ event => setQuery(event.target.value) }
onFocus={ () => setIsFocused(true) }
onBlur={ () =>
{
// Defer hide so onClick on a suggestion fires first
if(blurTimerRef.current) clearTimeout(blurTimerRef.current);
blurTimerRef.current = setTimeout(() => setIsFocused(false), 120);
} }
onKeyDown={ event =>
{
if(event.key === 'Enter') submitLookup();
if(event.key === 'Escape') setIsFocused(false);
} } />
</div>
<Button gap={ 1 } disabled={ isUserLoading } onClick={ submitLookup }>
<FaSearch size={ 10 } />
<span>{ LocalizeText('housekeeping.user.search.button') }</span>
</Button>
</div>
{ showSuggestionPanel &&
<div className="absolute left-0 right-0 top-full mt-1 z-30 rounded border border-zinc-200 bg-white shadow-lg max-h-[200px] overflow-y-auto">
{ userSuggestions.length > 0
? userSuggestions.map(entry =>
{
const isChecked = selectedUserIds.includes(entry.id);
return (
<div
key={ entry.id }
className="w-full flex items-center gap-2 px-2 py-1 text-xs hover:bg-sky-50 border-b border-zinc-100 last:border-b-0"
onMouseDown={ event => event.preventDefault() }>
<input
type="checkbox"
checked={ isChecked }
onChange={ () => toggleUserSelection(entry.id) }
title={ isChecked ? LocalizeText('housekeeping.bulk.clear') : LocalizeText('housekeeping.bulk.apply') }
className="shrink-0" />
<button
className="flex items-center gap-2 grow text-left"
onClick={ () =>
{
setQuery(entry.username);
setIsFocused(false);
lookupUserById(entry.id);
} }>
<FaCircle size={ 6 } className={ entry.online ? 'text-emerald-500' : 'text-zinc-400' } />
<span className="font-medium truncate grow">{ entry.username }</span>
<span className="text-[10px] text-zinc-500 shrink-0">#{ entry.id } · r{ entry.rank }</span>
</button>
</div>
);
})
: recentUsers.map(entry => (
<button
key={ entry.id }
className="w-full flex items-center gap-2 px-2 py-1 text-left text-xs hover:bg-sky-50 border-b border-zinc-100 last:border-b-0"
onMouseDown={ event => event.preventDefault() }
onClick={ () =>
{
setQuery(entry.label);
setIsFocused(false);
lookupUserById(entry.id);
} }>
<span className="text-[10px] text-zinc-400 uppercase shrink-0">recent</span>
<span className="font-medium truncate grow">{ entry.label }</span>
<span className="text-[10px] text-zinc-500 shrink-0">#{ entry.id }</span>
</button>
)) }
</div> }
</div>
{/* Bulk selection footer — appears whenever at least one user
is checked in the autocomplete dropdown. Applies the same
sanction (using the current reason + duration controls
below) to every selected user; ≥5 selected triggers a
themed confirm modal. */}
{ selectedUserIds.length > 0 &&
<div className="flex items-center gap-1 flex-wrap rounded border border-sky-300 bg-sky-50 p-1.5">
<span className="text-[10px] uppercase tracking-wide font-semibold text-sky-800 mr-1">
{ LocalizeText('housekeeping.bulk.label', [ 'count' ], [ String(selectedUserIds.length) ]) }
</span>
<Button size="sm" variant="danger" disabled={ isActionPending } onClick={ bulkBan }>
<FaBan size={ 10 } />
<span className="ml-1">{ LocalizeText('housekeeping.action.ban_h', [ 'h' ], [ String(banHours) ]) }</span>
</Button>
<Button size="sm" variant="warning" disabled={ isActionPending } onClick={ bulkMute }>
<FaVolumeMute size={ 10 } />
<span className="ml-1">{ LocalizeText('housekeeping.action.mute_min', [ 'm' ], [ String(muteMinutes) ]) }</span>
</Button>
<Button size="sm" variant="warning" disabled={ isActionPending } onClick={ bulkKick }>
<FaUserSlash size={ 10 } />
<span className="ml-1">{ LocalizeText('housekeeping.action.kick') }</span>
</Button>
<button
className="ml-auto text-zinc-500 hover:text-rose-600 px-1"
onClick={ clearUserSelection }
title={ LocalizeText('housekeeping.bulk.clear') }>
<FaTimes size={ 10 } />
</button>
</div> }
{ /* Selected user hero card */ }
{ selectedUser
? (
<div className="relative overflow-hidden rounded-lg border border-sky-200 bg-gradient-to-br from-sky-50 via-white to-emerald-50 p-3 shadow-sm">
<div className="flex items-start gap-3">
{ /* Live avatar head — renders the selected user's
in-game figure as a head crop. Falls back to
the modtools sprite when figure is empty (e.g.
a never-logged-in account).
Implementation note: `LayoutAvatarImageView`
is internally a fixed 90x130 background-image
box anchored at left:-2px (see
`src/common/layout/LayoutAvatarImageView.tsx`).
The earlier approach of forcing the box to
28x28 + transform:scale(0.42) was wrong on
two counts: clamping width/height clipped
the head BEFORE the transform ran, and the
transform-origin centered the residual on
a 28x28 layout that no longer contained
anything visible — the bubble rendered empty.
Correct pattern (same one `GroupMembersView`
uses for its 40x50 head bubbles): leave the
avatar element at its natural 90x130 size,
position it absolutely with negative offsets
so the head sits centered in the viewport,
and let `overflow-hidden` on the parent crop
the rest. No scale, no width override. */ }
<div className="relative rounded-full bg-sky-100 ring-2 ring-sky-200 shrink-0 w-[50px] h-[50px] overflow-hidden">
{ selectedUser.figure
? <LayoutAvatarImageView classNames={ [ '!absolute', '!-left-[20px]', '!-top-[20px]' ] } direction={ 2 } figure={ selectedUser.figure } headOnly={ true } />
: <span className="absolute inset-0 m-auto nitro-icon nitro-icon-hk-hero icon-modtools" /> }
</div>
<div className="grow min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="font-bold text-base truncate">{ selectedUser.username }</span>
<span className="text-[10px] text-zinc-500 tabular-nums">#{ selectedUser.id }</span>
<span className={ `inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-semibold border ${ selectedUser.online ? 'bg-emerald-50 border-emerald-200 text-emerald-700' : 'bg-zinc-100 border-zinc-200 text-zinc-500' }` }>
<FaCircle size={ 6 } className={ selectedUser.online ? 'animate-pulse' : '' } />
{ selectedUser.online ? 'online' : 'offline' }
</span>
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-violet-100 border border-violet-200 text-violet-700">
<FaIdBadge size={ 8 } />
{ selectedUser.rankName } · r{ selectedUser.rank }
</span>
{ selectedUser.isBanned &&
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-semibold bg-rose-100 border border-rose-200 text-rose-700"><FaBan size={ 8 } /> banned</span> }
{ selectedUser.isMuted &&
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-semibold bg-amber-100 border border-amber-200 text-amber-700"><FaVolumeMute size={ 8 } /> muted</span> }
{ selectedUser.isTradeLocked &&
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[10px] font-semibold bg-fuchsia-100 border border-fuchsia-200 text-fuchsia-700"><FaLock size={ 8 } /> trade-lock</span> }
</div>
<div className="text-xs text-zinc-600 truncate mt-0.5 italic">{ selectedUser.motto || '—' }</div>
<div className="grid grid-cols-3 gap-1 text-[10px] mt-2">
{ /* LayoutCurrencyIcon resolves `currency.asset.icon.url`
(`${images.url}/wallet/%type%.png`) — type=-1 is the
credits coin, type=0 the ducket/pixel, type=5 the
diamond. Sizes default to 15x15 which fits the badge
row without extra overrides. */ }
<div className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-amber-50 border border-amber-200" title={ LocalizeText('housekeeping.user.credits') }>
<LayoutCurrencyIcon type={ -1 } />
<span className="tabular-nums font-semibold text-amber-800">{ selectedUser.creditsBalance.toLocaleString() }</span>
</div>
<div className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-orange-50 border border-orange-200" title={ LocalizeText('housekeeping.user.duckets') }>
<LayoutCurrencyIcon type={ 0 } />
<span className="tabular-nums font-semibold text-orange-800">{ selectedUser.ducketsBalance.toLocaleString() }</span>
</div>
<div className="flex items-center gap-1 px-1.5 py-0.5 rounded bg-sky-50 border border-sky-200" title={ LocalizeText('housekeeping.user.diamonds') }>
<LayoutCurrencyIcon type={ 5 } />
<span className="tabular-nums font-semibold text-sky-800">{ selectedUser.diamondsBalance.toLocaleString() }</span>
</div>
</div>
{ selectedUser.email &&
<div className="flex items-center gap-1 text-[10px] text-zinc-500 mt-1.5 truncate" title={ selectedUser.email }>
<FaEnvelope size={ 8 } />
<span className="truncate">{ selectedUser.email }</span>
</div> }
</div>
<button
className="text-zinc-400 hover:text-rose-600 transition-colors p-1"
onClick={ () => setSelectedUser(null) }
title={ LocalizeText('housekeeping.user.clear') }>
<FaTimes size={ 12 } />
</button>
</div>
</div>
)
: (
<div className="flex items-center gap-2 rounded-lg border border-dashed border-zinc-300 bg-zinc-50/50 p-3 text-xs text-zinc-500 italic">
<FaUserSlash size={ 14 } />
{ LocalizeText('housekeeping.user.none') }
</div>
) }
{ /* Live in-room actions */ }
{ selectedUser && selectedUser.online &&
<div className="rounded-md border border-amber-200 bg-gradient-to-r from-amber-50 to-orange-50 p-1.5 flex items-center gap-1 flex-wrap shadow-sm">
<span className="text-[10px] uppercase tracking-wider font-bold text-amber-800 mr-1 flex items-center gap-1">
<FaBolt size={ 9 } className="text-amber-500" />
{ LocalizeText('housekeeping.user.live.label') }
</span>
<Button size="sm" variant="warning" disabled={ isActionPending } onClick={ () => kickFromCurrentRoom(selectedUser.id) }>
{ LocalizeText('housekeeping.user.live.kick') }
</Button>
<Button size="sm" variant="warning" disabled={ isActionPending } onClick={ () => muteInCurrentRoom(selectedUser.id, 2) }>
{ LocalizeText('housekeeping.user.live.mute_2m') }
</Button>
<Button size="sm" variant="warning" disabled={ isActionPending } onClick={ () => muteInCurrentRoom(selectedUser.id, 10) }>
{ LocalizeText('housekeeping.user.live.mute_10m') }
</Button>
<Button size="sm" variant="danger" disabled={ isActionPending } onClick={ () => banFromCurrentRoom(selectedUser.id, 'hour') }>
{ LocalizeText('housekeeping.user.live.ban_h') }
</Button>
<Button size="sm" variant="danger" disabled={ isActionPending } onClick={ () => banFromCurrentRoom(selectedUser.id, 'day') }>
{ LocalizeText('housekeeping.user.live.ban_d') }
</Button>
</div> }
{ /* Sanction template + Reason — grouped surface */ }
<div className="flex flex-col gap-1.5 rounded-md border border-zinc-200 bg-zinc-50/50 p-2">
<div className="flex items-center gap-1.5">
<label className="text-[10px] uppercase tracking-wider font-semibold opacity-60 shrink-0">Template</label>
<select
className="grow px-1.5 py-0.5 rounded border border-zinc-300 bg-white text-xs focus:outline-none focus:ring-1 focus:ring-sky-400"
value={ templateId }
onChange={ event => applyTemplate(event.target.value) }>
<option value=""></option>
{ HK_SANCTION_TEMPLATES.map(template => (
<option key={ template.id } value={ template.id }>{ template.name }</option>
)) }
</select>
</div>
<label className="text-[10px] uppercase tracking-wider font-semibold opacity-60">{ LocalizeText('housekeeping.field.reason') }</label>
<textarea
className="min-h-[48px] px-2 py-1 rounded text-sm border border-zinc-300 bg-white focus:outline-none focus:ring-1 focus:ring-sky-400 placeholder:text-zinc-400"
placeholder={ LocalizeText('housekeeping.field.reason.placeholder') }
value={ reason }
onChange={ event => setReason(event.target.value) } />
</div>
{ /* Sanctions */ }
<label className="text-[10px] uppercase tracking-wider font-semibold opacity-60 -mb-0.5">{ LocalizeText('housekeeping.field.duration') }</label>
<div className="grid grid-cols-2 gap-1.5">
<div className="flex items-center gap-1 rounded-md border border-rose-200 bg-rose-50/40 px-1.5 py-1">
<input
type="number"
min={ 1 }
className="w-14 px-1 py-0.5 rounded border border-rose-200 bg-white text-xs tabular-nums focus:outline-none focus:ring-1 focus:ring-rose-400"
value={ banHours }
onChange={ event => setBanHours(parseInt(event.target.value) || 0) } />
<span className="text-[10px] text-rose-700">h</span>
<Button variant="danger" disabled={ disableActions } className="grow ml-auto" onClick={ () => banUser(selectedUser.id, reasonOrDefault, banHours) }>
<FaBan size={ 10 } />
<span className="ml-1">{ LocalizeText('housekeeping.action.ban_h', [ 'h' ], [ String(banHours) ]) }</span>
</Button>
</div>
<div className="flex items-center gap-1 rounded-md border border-amber-200 bg-amber-50/40 px-1.5 py-1">
<input
type="number"
min={ 1 }
className="w-14 px-1 py-0.5 rounded border border-amber-200 bg-white text-xs tabular-nums focus:outline-none focus:ring-1 focus:ring-amber-400"
value={ muteMinutes }
onChange={ event => setMuteMinutes(parseInt(event.target.value) || 0) } />
<span className="text-[10px] text-amber-700">m</span>
<Button variant="warning" disabled={ disableActions } className="grow ml-auto" onClick={ () => muteUser(selectedUser.id, reasonOrDefault, muteMinutes) }>
<FaVolumeMute size={ 10 } />
<span className="ml-1">{ LocalizeText('housekeeping.action.mute_min', [ 'm' ], [ String(muteMinutes) ]) }</span>
</Button>
</div>
<div className="flex items-center gap-1 rounded-md border border-fuchsia-200 bg-fuchsia-50/40 px-1.5 py-1">
<input
type="number"
min={ 1 }
className="w-14 px-1 py-0.5 rounded border border-fuchsia-200 bg-white text-xs tabular-nums focus:outline-none focus:ring-1 focus:ring-fuchsia-400"
value={ tradeLockHours }
onChange={ event => setTradeLockHours(parseInt(event.target.value) || 0) } />
<span className="text-[10px] text-fuchsia-700">h</span>
<Button variant="secondary" disabled={ disableActions } className="grow ml-auto" onClick={ () => tradeLockUser(selectedUser.id, tradeLockHours, reasonOrDefault) }>
<FaLock size={ 10 } />
<span className="ml-1">{ LocalizeText('housekeeping.action.trade_lock_h', [ 'h' ], [ String(tradeLockHours) ]) }</span>
</Button>
</div>
<Button variant="warning" disabled={ disableActions } onClick={ () => kickUser(selectedUser.id, reasonOrDefault) }>
<FaUserSlash size={ 10 } />
<span className="ml-1">{ LocalizeText('housekeeping.action.kick') }</span>
</Button>
<Button variant="danger" disabled={ disableActions || !selectedUser?.isBanned } onClick={ () => unbanUser(selectedUser.id) }>
<FaExclamationTriangle size={ 10 } />
<span className="ml-1">{ LocalizeText('housekeeping.action.unban') }</span>
</Button>
<Button variant="danger" disabled={ disableActions } onClick={ () => forceDisconnectUser(selectedUser.id, reasonOrDefault) }>
<FaPlug size={ 10 } />
<span className="ml-1">{ LocalizeText('housekeeping.action.force_disconnect') }</span>
</Button>
</div>
{ /* Rank + password — privileged actions card */ }
<div className="grid grid-cols-2 gap-1.5 rounded-md border border-violet-200 bg-violet-50/40 p-2">
<div className="flex items-center gap-1">
<input
type="number"
min={ 1 }
max={ 12 }
className="w-14 px-1.5 py-0.5 rounded border border-violet-200 bg-white text-xs tabular-nums focus:outline-none focus:ring-1 focus:ring-violet-400"
value={ rankDraft }
onChange={ event => setRankDraft(parseInt(event.target.value) || 0) } />
<Button variant="primary" disabled={ disableActions } className="grow" onClick={ () => setUserRank(selectedUser.id, rankDraft) }>
<FaUserShield size={ 10 } />
<span className="ml-1">{ LocalizeText('housekeeping.action.set_rank') }</span>
</Button>
</div>
<Button variant="dark" disabled={ disableActions } onClick={ () => resetUserPassword(selectedUser.id) }>
<FaKey size={ 10 } />
<span className="ml-1">{ LocalizeText('housekeeping.action.reset_password') }</span>
</Button>
</div>
<div className="text-[10px] text-zinc-500 italic pt-1 border-t border-zinc-200 flex items-center gap-1">
<FaEnvelope size={ 9 } className="opacity-50" />
{ LocalizeText('housekeeping.user.audit_hint') }
</div>
</div>
);
};
+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';