From eeab548917e54f2b8737f2df1e846b70879df097 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 24 May 2026 16:24:08 +0200 Subject: [PATCH 1/3] feat(housekeeping): in-client admin panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` — `` mounted alongside ``. - `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. --- .../housekeeping-texts-en.example | 149 +++++ .../housekeeping-texts-it.example | 149 +++++ .../housekeeping/HousekeepingActionType.ts | 36 ++ src/api/housekeeping/HousekeepingApi.ts | 384 +++++++++++++ .../housekeeping/HousekeepingConfig.test.ts | 67 +++ src/api/housekeeping/HousekeepingConfig.ts | 61 +++ .../HousekeepingFormatters.test.ts | 107 ++++ .../housekeeping/HousekeepingFormatters.ts | 60 ++ .../housekeeping/HousekeepingHttpClient.ts | 126 +++++ src/api/housekeeping/HousekeepingI18n.test.ts | 120 ++++ .../housekeeping/HousekeepingMetrics.test.ts | 127 +++++ src/api/housekeeping/HousekeepingMetrics.ts | 109 ++++ .../HousekeepingRecentLookups.test.ts | 63 +++ .../housekeeping/HousekeepingRecentLookups.ts | 77 +++ .../HousekeepingSanctionTemplates.test.ts | 73 +++ .../HousekeepingSanctionTemplates.ts | 50 ++ .../HousekeepingValidation.test.ts | 125 +++++ .../housekeeping/HousekeepingValidation.ts | 66 +++ src/api/housekeeping/IHousekeepingTypes.ts | 86 +++ src/api/housekeeping/index.ts | 10 + src/api/index.ts | 1 + src/api/nitro/awaitMessageEvent.ts | 118 ++++ src/api/nitro/index.ts | 1 + src/components/MainView.tsx | 2 + .../housekeeping/HousekeepingStatusBanner.tsx | 63 +++ .../housekeeping/HousekeepingView.tsx | 207 +++++++ .../views/audit/HousekeepingAuditTab.tsx | 224 ++++++++ .../HousekeepingDashboardTab.test.tsx | 138 +++++ .../dashboard/HousekeepingDashboardTab.tsx | 319 +++++++++++ .../views/economy/HousekeepingEconomyTab.tsx | 174 ++++++ .../views/rooms/HousekeepingRoomsTab.tsx | 198 +++++++ .../views/users/HousekeepingUsersTab.tsx | 439 +++++++++++++++ src/hooks/housekeeping/index.ts | 4 + src/hooks/housekeeping/useHousekeeping.ts | 17 + .../housekeeping/useHousekeepingActions.ts | 467 ++++++++++++++++ .../housekeeping/useHousekeepingBulk.test.ts | 64 +++ .../housekeeping/useHousekeepingConfirm.ts | 32 ++ .../housekeeping/useHousekeepingStore.ts | 513 ++++++++++++++++++ src/hooks/index.ts | 1 + 39 files changed, 5027 insertions(+) create mode 100644 public/configuration/housekeeping-texts-en.example create mode 100644 public/configuration/housekeeping-texts-it.example create mode 100644 src/api/housekeeping/HousekeepingActionType.ts create mode 100644 src/api/housekeeping/HousekeepingApi.ts create mode 100644 src/api/housekeeping/HousekeepingConfig.test.ts create mode 100644 src/api/housekeeping/HousekeepingConfig.ts create mode 100644 src/api/housekeeping/HousekeepingFormatters.test.ts create mode 100644 src/api/housekeeping/HousekeepingFormatters.ts create mode 100644 src/api/housekeeping/HousekeepingHttpClient.ts create mode 100644 src/api/housekeeping/HousekeepingI18n.test.ts create mode 100644 src/api/housekeeping/HousekeepingMetrics.test.ts create mode 100644 src/api/housekeeping/HousekeepingMetrics.ts create mode 100644 src/api/housekeeping/HousekeepingRecentLookups.test.ts create mode 100644 src/api/housekeeping/HousekeepingRecentLookups.ts create mode 100644 src/api/housekeeping/HousekeepingSanctionTemplates.test.ts create mode 100644 src/api/housekeeping/HousekeepingSanctionTemplates.ts create mode 100644 src/api/housekeeping/HousekeepingValidation.test.ts create mode 100644 src/api/housekeeping/HousekeepingValidation.ts create mode 100644 src/api/housekeeping/IHousekeepingTypes.ts create mode 100644 src/api/housekeeping/index.ts create mode 100644 src/api/nitro/awaitMessageEvent.ts create mode 100644 src/components/housekeeping/HousekeepingStatusBanner.tsx create mode 100644 src/components/housekeeping/HousekeepingView.tsx create mode 100644 src/components/housekeeping/views/audit/HousekeepingAuditTab.tsx create mode 100644 src/components/housekeeping/views/dashboard/HousekeepingDashboardTab.test.tsx create mode 100644 src/components/housekeeping/views/dashboard/HousekeepingDashboardTab.tsx create mode 100644 src/components/housekeeping/views/economy/HousekeepingEconomyTab.tsx create mode 100644 src/components/housekeeping/views/rooms/HousekeepingRoomsTab.tsx create mode 100644 src/components/housekeeping/views/users/HousekeepingUsersTab.tsx create mode 100644 src/hooks/housekeeping/index.ts create mode 100644 src/hooks/housekeeping/useHousekeeping.ts create mode 100644 src/hooks/housekeeping/useHousekeepingActions.ts create mode 100644 src/hooks/housekeeping/useHousekeepingBulk.test.ts create mode 100644 src/hooks/housekeeping/useHousekeepingConfirm.ts create mode 100644 src/hooks/housekeeping/useHousekeepingStore.ts diff --git a/public/configuration/housekeeping-texts-en.example b/public/configuration/housekeeping-texts-en.example new file mode 100644 index 0000000..b432660 --- /dev/null +++ b/public/configuration/housekeeping-texts-en.example @@ -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." +} diff --git a/public/configuration/housekeeping-texts-it.example b/public/configuration/housekeeping-texts-it.example new file mode 100644 index 0000000..80ea048 --- /dev/null +++ b/public/configuration/housekeeping-texts-it.example @@ -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." +} diff --git a/src/api/housekeeping/HousekeepingActionType.ts b/src/api/housekeeping/HousekeepingActionType.ts new file mode 100644 index 0000000..f62bea9 --- /dev/null +++ b/src/api/housekeeping/HousekeepingActionType.ts @@ -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]; diff --git a/src/api/housekeeping/HousekeepingApi.ts b/src/api/housekeeping/HousekeepingApi.ts new file mode 100644 index 0000000..57ea855 --- /dev/null +++ b/src/api/housekeeping/HousekeepingApi.ts @@ -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 => +{ + 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, { + 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 => + awaitMessageEvent(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 => +{ + const trimmed = (username || '').trim(); + + if(!trimmed) return null; + + SendMessageComposer(new HousekeepingFindUserByNameComposer(trimmed)); + + return awaitUserDetail(); +}; + +const findUserByIdViaPacket = async (userId: number): Promise => +{ + 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, expectedActionKey: string, timeoutMs = 15_000): Promise => +{ + SendMessageComposer(composer); + + try + { + return await awaitMessageEvent(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 => + runHkAction(new HousekeepingBanUserComposer(userId, reason || '', hours), 'user.ban'); + +const unbanUserViaPacket = (userId: number): Promise => + runHkAction(new HousekeepingUnbanUserComposer(userId), 'user.unban'); + +const muteUserViaPacket = (userId: number, reason: string, minutes: number): Promise => + runHkAction(new HousekeepingMuteUserComposer(userId, reason || '', minutes), 'user.mute'); + +const kickUserViaPacket = (userId: number, reason: string): Promise => + runHkAction(new HousekeepingKickUserComposer(userId, reason || ''), 'user.kick'); + +const forceDisconnectUserViaPacket = (userId: number, reason: string): Promise => + runHkAction(new HousekeepingForceDisconnectUserComposer(userId, reason || ''), 'user.disconnect'); + +const setUserRankViaPacket = (userId: number, rank: number): Promise => + runHkAction(new HousekeepingSetUserRankComposer(userId, rank), 'user.set_rank'); + +const tradeLockUserViaPacket = (userId: number, hours: number, reason: string): Promise => + runHkAction(new HousekeepingTradeLockUserComposer(userId, hours, reason || ''), 'user.trade_lock'); + +const resetUserPasswordViaPacket = (userId: number): Promise => + 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 => +{ + if(!Number.isFinite(roomId) || roomId <= 0) return Promise.resolve(null); + + SendMessageComposer(new HousekeepingFindRoomByIdComposer(roomId)); + + return awaitMessageEvent(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 => +{ + const trimmed = (name || '').trim(); + + if(!trimmed) return Promise.resolve([]); + + SendMessageComposer(new HousekeepingSearchRoomsComposer(trimmed, true, 50)); + + return awaitMessageEvent(HousekeepingRoomListEvent, { + timeoutMs: 8_000, + select: event => event.getParser()?.rooms.map(mapRoom) ?? [] + }); +}; + +const searchRoomsViaPacket = (prefix: string, signal?: AbortSignal): Promise => +{ + const trimmed = (prefix || '').trim(); + + if(!trimmed) return Promise.resolve([]); + + SendMessageComposer(new HousekeepingSearchRoomsComposer(trimmed, false, 8)); + + return awaitMessageEvent(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 => + runHkAction(new HousekeepingRoomStateComposer(roomId, open), open ? 'room.open' : 'room.close'); + +const muteRoomViaPacket = (roomId: number, minutes: number): Promise => + runHkAction(new HousekeepingMuteRoomComposer(roomId, minutes), 'room.mute'); + +const kickAllFromRoomViaPacket = (roomId: number): Promise => + runHkAction(new HousekeepingKickAllFromRoomComposer(roomId), 'room.kick_all'); + +const transferRoomOwnershipViaPacket = (roomId: number, newOwnerId: number): Promise => + runHkAction(new HousekeepingTransferRoomOwnershipComposer(roomId, newOwnerId), 'room.transfer'); + +const deleteRoomViaPacket = (roomId: number): Promise => + runHkAction(new HousekeepingDeleteRoomComposer(roomId), 'room.delete'); + +const CURRENCY_DUCKETS = 0; +const CURRENCY_DIAMONDS = 5; + +const giveCreditsViaPacket = (userId: number, amount: number): Promise => + runHkAction(new HousekeepingGiveCreditsComposer(userId, amount), 'user.give_credits'); + +const giveDucketsViaPacket = (userId: number, amount: number): Promise => + runHkAction(new HousekeepingGiveCurrencyComposer(userId, CURRENCY_DUCKETS, amount), `user.give_currency_${ CURRENCY_DUCKETS }`); + +const giveDiamondsViaPacket = (userId: number, amount: number): Promise => + runHkAction(new HousekeepingGiveCurrencyComposer(userId, CURRENCY_DIAMONDS, amount), `user.give_currency_${ CURRENCY_DIAMONDS }`); + +const grantItemViaPacket = (userId: number, itemId: number, quantity: number): Promise => + runHkAction(new HousekeepingGrantItemComposer(userId, itemId, quantity), 'user.grant_item'); + +const setHcSubscriptionViaPacket = (userId: number, days: number): Promise => + runHkAction(new HousekeepingSetHcSubscriptionComposer(userId, days), 'user.set_hc'); + +const sendHotelAlertViaPacket = (message: string): Promise => + 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 => +{ + SendMessageComposer(new HousekeepingGetDashboardComposer()); + + return awaitMessageEvent(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 => +{ + const safeLimit = Math.max(1, Math.min(500, Math.floor(limit || 50))); + + SendMessageComposer(new HousekeepingListActionLogComposer(safeLimit)); + + return awaitMessageEvent(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; diff --git a/src/api/housekeeping/HousekeepingConfig.test.ts b/src/api/housekeeping/HousekeepingConfig.test.ts new file mode 100644 index 0000000..051b7f1 --- /dev/null +++ b/src/api/housekeeping/HousekeepingConfig.test.ts @@ -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 + ]); + }); +}); diff --git a/src/api/housekeeping/HousekeepingConfig.ts b/src/api/housekeeping/HousekeepingConfig.ts new file mode 100644 index 0000000..b706eae --- /dev/null +++ b/src/api/housekeeping/HousekeepingConfig.ts @@ -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(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(HOUSEKEEPING_MODE_KEY, 'full')); + +const LIGHT_TABS: ReadonlySet = new Set([ + 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)); +}; diff --git a/src/api/housekeeping/HousekeepingFormatters.test.ts b/src/api/housekeeping/HousekeepingFormatters.test.ts new file mode 100644 index 0000000..dbe8d14 --- /dev/null +++ b/src/api/housekeeping/HousekeepingFormatters.test.ts @@ -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'); + }); +}); diff --git a/src/api/housekeeping/HousekeepingFormatters.ts b/src/api/housekeeping/HousekeepingFormatters.ts new file mode 100644 index 0000000..73dfadb --- /dev/null +++ b/src/api/housekeeping/HousekeepingFormatters.ts @@ -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(); +}; diff --git a/src/api/housekeeping/HousekeepingHttpClient.ts b/src/api/housekeeping/HousekeepingHttpClient.ts new file mode 100644 index 0000000..fc3b32f --- /dev/null +++ b/src/api/housekeeping/HousekeepingHttpClient.ts @@ -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 => +{ + const token = getAccessToken(); + + return token ? { Authorization: `Bearer ${ token }` } : {}; +}; + +export interface HousekeepingRequestInit +{ + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + body?: unknown; + query?: Record; + 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 (path: string, init: HousekeepingRequestInit = {}): Promise => +{ + 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; + } +} diff --git a/src/api/housekeeping/HousekeepingI18n.test.ts b/src/api/housekeeping/HousekeepingI18n.test.ts new file mode 100644 index 0000000..100fb78 --- /dev/null +++ b/src/api/housekeeping/HousekeepingI18n.test.ts @@ -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 => +{ + 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 => +{ + 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(); + + 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([]); + }); +}); diff --git a/src/api/housekeeping/HousekeepingMetrics.test.ts b/src/api/housekeeping/HousekeepingMetrics.test.ts new file mode 100644 index 0000000..ec9cb1d --- /dev/null +++ b/src/api/housekeeping/HousekeepingMetrics.test.ts @@ -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); + }); +}); diff --git a/src/api/housekeeping/HousekeepingMetrics.ts b/src/api/housekeeping/HousekeepingMetrics.ts new file mode 100644 index 0000000..02747a6 --- /dev/null +++ b/src/api/housekeeping/HousekeepingMetrics.ts @@ -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, 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; diff --git a/src/api/housekeeping/HousekeepingRecentLookups.test.ts b/src/api/housekeeping/HousekeepingRecentLookups.test.ts new file mode 100644 index 0000000..90acfbb --- /dev/null +++ b/src/api/housekeeping/HousekeepingRecentLookups.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { pushRecentLookup, RECENT_LOOKUPS_LIMIT, RecentLookupEntry } from './HousekeepingRecentLookups'; + +const entry = (over: Partial = {}): 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); + }); +}); diff --git a/src/api/housekeeping/HousekeepingRecentLookups.ts b/src/api/housekeeping/HousekeepingRecentLookups.ts new file mode 100644 index 0000000..33e6eef --- /dev/null +++ b/src/api/housekeeping/HousekeepingRecentLookups.ts @@ -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; + + 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; diff --git a/src/api/housekeeping/HousekeepingSanctionTemplates.test.ts b/src/api/housekeeping/HousekeepingSanctionTemplates.test.ts new file mode 100644 index 0000000..d34efa3 --- /dev/null +++ b/src/api/housekeeping/HousekeepingSanctionTemplates.test.ts @@ -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([]); + }); +}); diff --git a/src/api/housekeeping/HousekeepingSanctionTemplates.ts b/src/api/housekeeping/HousekeepingSanctionTemplates.ts new file mode 100644 index 0000000..d060bf5 --- /dev/null +++ b/src/api/housekeeping/HousekeepingSanctionTemplates.ts @@ -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); diff --git a/src/api/housekeeping/HousekeepingValidation.test.ts b/src/api/housekeeping/HousekeepingValidation.test.ts new file mode 100644 index 0000000..474a10c --- /dev/null +++ b/src/api/housekeeping/HousekeepingValidation.test.ts @@ -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); + }); +}); diff --git a/src/api/housekeeping/HousekeepingValidation.ts b/src/api/housekeeping/HousekeepingValidation.ts new file mode 100644 index 0000000..0168ec6 --- /dev/null +++ b/src/api/housekeeping/HousekeepingValidation.ts @@ -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; +}; diff --git a/src/api/housekeeping/IHousekeepingTypes.ts b/src/api/housekeeping/IHousekeepingTypes.ts new file mode 100644 index 0000000..bf0bb1c --- /dev/null +++ b/src/api/housekeeping/IHousekeepingTypes.ts @@ -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; +} diff --git a/src/api/housekeeping/index.ts b/src/api/housekeeping/index.ts new file mode 100644 index 0000000..7a4d18b --- /dev/null +++ b/src/api/housekeeping/index.ts @@ -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'; diff --git a/src/api/index.ts b/src/api/index.ts index 321608a..d01bfb1 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -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'; diff --git a/src/api/nitro/awaitMessageEvent.ts b/src/api/nitro/awaitMessageEvent.ts new file mode 100644 index 0000000..0a40bd9 --- /dev/null +++ b/src/api/nitro/awaitMessageEvent.ts @@ -0,0 +1,118 @@ +import { GetCommunication, IMessageEvent } from '@nitrots/nitro-renderer'; + +export interface AwaitMessageEventInit +{ + 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 = (eventCtor: new (callback: (event: T) => void) => T, init: AwaitMessageEventInit = {}): Promise => +{ + const { timeoutMs = DEFAULT_TIMEOUT_MS, signal, accept, select } = init; + + return new Promise((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 | 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 }); + } + }); +}; diff --git a/src/api/nitro/index.ts b/src/api/nitro/index.ts index c2ecb72..bb580e5 100644 --- a/src/api/nitro/index.ts +++ b/src/api/nitro/index.ts @@ -1,3 +1,4 @@ +export * from './awaitMessageEvent'; export * from './CreateLinkEvent'; export * from './GetConfigurationValue'; export * from './OpenUrl'; diff --git a/src/components/MainView.tsx b/src/components/MainView.tsx index 1d2c5d0..5b1225f 100644 --- a/src/components/MainView.tsx +++ b/src/components/MainView.tsx @@ -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 => + diff --git a/src/components/housekeeping/HousekeepingStatusBanner.tsx b/src/components/housekeeping/HousekeepingStatusBanner.tsx new file mode 100644 index 0000000..c33659f --- /dev/null +++ b/src/components/housekeeping/HousekeepingStatusBanner.tsx @@ -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 ( +
+ + { LocalizeText('housekeeping.action.pending') } +
+ ); + } + + 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 ( +
+ + { message } + +
+ ); +}; diff --git a/src/components/housekeeping/HousekeepingView.tsx b/src/components/housekeeping/HousekeepingView.tsx new file mode 100644 index 0000000..219ba60 --- /dev/null +++ b/src/components/housekeeping/HousekeepingView.tsx @@ -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/[//
] — 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 ; + case HousekeepingTabId.ECONOMY: return ; + case HousekeepingTabId.AUDIT: return ; + case HousekeepingTabId.USERS: return ; + case HousekeepingTabId.DASHBOARD: + default: + return ; + } + }, [ 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 ( + + + closePanel() } /> + + { showDashboard && + setActiveTab(HousekeepingTabId.DASHBOARD) }> +
+ + { LocalizeText('housekeeping.tab.dashboard') } +
+
} + setActiveTab(HousekeepingTabId.USERS) }> +
+ + { LocalizeText('housekeeping.tab.users') } +
+
+ setActiveTab(HousekeepingTabId.ROOMS) }> +
+ + { LocalizeText('housekeeping.tab.rooms') } +
+
+ { showEconomy && + setActiveTab(HousekeepingTabId.ECONOMY) }> +
+ + { LocalizeText('housekeeping.tab.economy') } +
+
} + { showAudit && + setActiveTab(HousekeepingTabId.AUDIT) }> +
+ + { LocalizeText('housekeeping.tab.audit') } +
+
} +
+ + + { activeView } + +
+
+ ); +}; diff --git a/src/components/housekeeping/views/audit/HousekeepingAuditTab.tsx b/src/components/housekeeping/views/audit/HousekeepingAuditTab.tsx new file mode 100644 index 0000000..6141da7 --- /dev/null +++ b/src/components/housekeeping/views/audit/HousekeepingAuditTab.tsx @@ -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 = { + 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('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('nitro.housekeeping.audit.target_filter', 'all'); + const [ successFilter, setSuccessFilter ] = useLocalStorage('nitro.housekeeping.audit.success_filter', 'all'); + const [ query, setQuery ] = useLocalStorage('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 ( +
+ { /* Header w/ counts + refresh */ } +
+

+ + { LocalizeText('housekeeping.audit.title') } + { actionLog.length > 0 && + + { successCount } + { failureCount > 0 && + { failureCount } } + } +

+ +
+ + { /* Filter row */ } +
+ { (Object.keys(FILTER_LABELS) as TargetFilter[]).map(filter => ( + + )) } + + + +
+ + setQuery(event.target.value) } /> + + { filtered.length === 0 + ? ( +
+ + { actionLog.length === 0 ? LocalizeText('housekeeping.audit.empty') : LocalizeText('housekeeping.audit.no_match') } +
+ ) + : ( +
    + { filtered.map(entry => ( +
  • + + { formatRelativePast(entry.timestamp) } + + + { entry.actorName } + + + + { entry.targetType } + { entry.targetLabel } + + + { entry.action } + +
  • + )) } +
+ ) } + + { telemetryEnabled && +
+ + { isTelemetryExpanded && +
+ { metricsByAction.size === 0 + ?
{ LocalizeText('housekeeping.telemetry.empty') }
+ : ( + + + + + + + + + + + + + { [ ...metricsByAction.entries() ] + .map(([ action, sample ]) => sampleToMetric(action, sample)) + .sort((a, b) => b.count - a.count) + .map(metric => ( + + + + + + + + + )) } + +
actionnerrlastp50p95
{ metric.action }{ metric.count } 0 ? 'text-rose-700 font-semibold' : 'text-zinc-500' }` }>{ metric.errors }{ Math.round(metric.lastMs) }ms{ Math.round(metric.p50Ms) }ms{ Math.round(metric.p95Ms) }ms
+ ) } +
+ +
+
} +
} +
+ ); +}; diff --git a/src/components/housekeeping/views/dashboard/HousekeepingDashboardTab.test.tsx b/src/components/housekeeping/views/dashboard/HousekeepingDashboardTab.test.tsx new file mode 100644 index 0000000..45aa759 --- /dev/null +++ b/src/components/housekeeping/views/dashboard/HousekeepingDashboardTab.test.tsx @@ -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(); + + expect(container.querySelectorAll('.animate-pulse').length).toBeGreaterThanOrEqual(4); + }); + + it('renders the unavailable banner when not loading and no data', () => + { + render(); + + 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(); + + 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(); + + 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(); + + expect(screen.getByText('housekeeping.dashboard.recent_lookups')).toBeTruthy(); + expect(screen.getByText('alice')).toBeTruthy(); + expect(screen.getByText('lobby')).toBeTruthy(); + }); +}); diff --git a/src/components/housekeeping/views/dashboard/HousekeepingDashboardTab.tsx b/src/components/housekeeping/views/dashboard/HousekeepingDashboardTab.tsx new file mode 100644 index 0000000..4cb1b8c --- /dev/null +++ b/src/components/housekeeping/views/dashboard/HousekeepingDashboardTab.tsx @@ -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, 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, 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 = ({ icon, label, value, subtle, tone = 'sky', onClick }) => +{ + const interactive = !!onClick; + + return ( +
+
{ icon }
+
+ { label } + { value } + { subtle && + { subtle } } +
+
+ ); +}; + +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(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 ( +
+ { /* Header row: title + live status badge + refresh */ } +
+

+ + { LocalizeText('housekeeping.dashboard.title') } +

+
+ + + { isStale ? `stale · ${ ageLabel }` : `live · ${ ageLabel }` } + + +
+
+ + { !dashboard && isDashboardLoading && +
+ { Array.from({ length: 4 }).map((_, i) => ( +
+ )) } +
} + + { !dashboard && !isDashboardLoading && +
+ + { LocalizeText('housekeeping.dashboard.unavailable') } +
} + + { dashboard && + <> + { /* Hero card: BIG online count + pulsing dot + peak today */ } +
+
+
+
+ +
+
+
+ + { LocalizeText('housekeeping.dashboard.online') } +
+
{ formatCompactNumber(dashboard.onlineUsers) }
+
+ { LocalizeText('housekeeping.dashboard.total_users', [ 'count' ], [ dashboard.totalUsers.toLocaleString() ]) } +
+
+
+
+
+ + { LocalizeText('housekeeping.dashboard.peak_today') } +
+
{ formatCompactNumber(dashboard.peakOnlineToday) }
+
+ { LocalizeText('housekeeping.dashboard.peak_alltime', [ 'count' ], [ formatCompactNumber(dashboard.peakOnlineAllTime) ]) } +
+
+
+
+ + { /* 4-card grid */ } +
+ } + 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) } /> + } + 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' } /> +
+ } + label={ LocalizeText('housekeeping.dashboard.server') } + value={ formatUptime(dashboard.serverUptimeSeconds) } + subtle={ dashboard.serverVersion } + tone="violet" /> +
+
+ + { /* Quick hotel-alert inline */ } +
+ +
+ 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 } /> + +
+
+ } + + { /* Recent sanctions */ } + { recentSanctions.length > 0 && +
+

+ { LocalizeText('housekeeping.dashboard.recent_sanctions') } +

+
    + { recentSanctions.map(entry => ( +
  • + { formatRelativePast(entry.timestamp) } + { entry.actorName } + + { entry.targetLabel } + { entry.action } +
  • + )) } +
+
} + + { /* Recent lookups — clickable pills that re-select the target */ } + { recentLookups.length > 0 && +
+

+ { LocalizeText('housekeeping.dashboard.recent_lookups') } +

+
+ { recentLookups.map((entry, index) => ( + + )) } +
+
} +
+ ); +}; diff --git a/src/components/housekeeping/views/economy/HousekeepingEconomyTab.tsx b/src/components/housekeeping/views/economy/HousekeepingEconomyTab.tsx new file mode 100644 index 0000000..2009aa6 --- /dev/null +++ b/src/components/housekeeping/views/economy/HousekeepingEconomyTab.tsx @@ -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(1000); + const [ ducketsAmount, setDucketsAmount ] = useState(100); + const [ diamondsAmount, setDiamondsAmount ] = useState(10); + const [ itemId, setItemId ] = useState(0); + const [ itemQuantity, setItemQuantity ] = useState(1); + const [ hcDays, setHcDays ] = useState(31); + const [ alertText, setAlertText ] = useState(''); + + const disableUserActions = !selectedUser || isActionPending; + const disableHotelActions = isActionPending; + const trimmedAlert = alertText.trim(); + + return ( +
+ { /* Target banner */ } + { !selectedUser + ? ( +
+ + { LocalizeText('housekeeping.economy.select_user') } +
+ ) + : ( +
+
+ { LocalizeText('housekeeping.economy.target', [ 'username', 'id' ], [ '', '' ]).replace(/[a-z]+:\s*/i, '').trim() || 'Target' } +
+
{ selectedUser.username } #{ selectedUser.id }
+
+ ) } + + { /* Currency grants — tone-coded surfaces */ } +
+ { /* Credits — amber */ } +
+ + setCreditsAmount(parseInt(event.target.value) || 0) } /> + +
+ + { /* Duckets — orange */ } +
+ + setDucketsAmount(parseInt(event.target.value) || 0) } /> + +
+ + { /* Diamonds — sky */ } +
+ + setDiamondsAmount(parseInt(event.target.value) || 0) } /> + +
+
+ + { /* Grant item card */ } +
+ +
+ setItemId(parseInt(event.target.value) || 0) } /> + setItemQuantity(parseInt(event.target.value) || 0) } /> + +
+
+ + { /* HC subscription */ } +
+ + setHcDays(parseInt(event.target.value) || 0) } /> + days + +
+ + { /* Hotel-wide alert */ } +
+ +