mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 23:16:21 +00:00
Merge pull request #157 from simoleo89/feat/housekeeping-panel
feat(housekeeping): in-client admin panel
This commit is contained in:
@@ -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];
|
||||
@@ -0,0 +1,384 @@
|
||||
import {
|
||||
HabboSearchComposer, HabboSearchResultEvent, HousekeepingActionLogEvent, HousekeepingActionResultEvent,
|
||||
HousekeepingBanUserComposer, HousekeepingDashboardEvent, HousekeepingDeleteRoomComposer,
|
||||
HousekeepingFindRoomByIdComposer, HousekeepingFindUserByIdComposer, HousekeepingFindUserByNameComposer,
|
||||
HousekeepingForceDisconnectUserComposer, HousekeepingGetDashboardComposer,
|
||||
HousekeepingGiveCreditsComposer, HousekeepingGiveCurrencyComposer, HousekeepingGrantItemComposer,
|
||||
HousekeepingKickAllFromRoomComposer, HousekeepingKickUserComposer, HousekeepingListActionLogComposer,
|
||||
HousekeepingMuteRoomComposer, HousekeepingMuteUserComposer, HousekeepingResetUserPasswordComposer,
|
||||
HousekeepingRoomData, HousekeepingRoomDetailEvent, HousekeepingRoomListEvent,
|
||||
HousekeepingRoomStateComposer, HousekeepingSearchRoomsComposer, HousekeepingSendHotelAlertComposer,
|
||||
HousekeepingSetHcSubscriptionComposer, HousekeepingSetUserRankComposer,
|
||||
HousekeepingTradeLockUserComposer, HousekeepingTransferRoomOwnershipComposer,
|
||||
HousekeepingUnbanUserComposer, HousekeepingUserDetailData, HousekeepingUserDetailEvent,
|
||||
IMessageComposer
|
||||
} from '@nitrots/nitro-renderer';
|
||||
import { awaitMessageEvent } from '../nitro/awaitMessageEvent';
|
||||
import { SendMessageComposer } from '../nitro/SendMessageComposer';
|
||||
import {
|
||||
IHousekeepingActionLogEntry, IHousekeepingActionResult, IHousekeepingDashboard,
|
||||
IHousekeepingRoom, IHousekeepingRoomSummary, IHousekeepingUser, IHousekeepingUserSummary
|
||||
} from './IHousekeepingTypes';
|
||||
|
||||
const USER_SEARCH_LIMIT = 8;
|
||||
|
||||
const searchUsersViaPacket = async (prefix: string, signal?: AbortSignal): Promise<IHousekeepingUserSummary[]> =>
|
||||
{
|
||||
SendMessageComposer(new HabboSearchComposer(prefix));
|
||||
|
||||
// Snapshot the parser inside the subscribe callback — the renderer
|
||||
// recycles parser instances after the callback returns, so any
|
||||
// post-await read of `event.getParser()` comes back null.
|
||||
return await awaitMessageEvent<HabboSearchResultEvent, IHousekeepingUserSummary[]>(HabboSearchResultEvent, {
|
||||
signal,
|
||||
timeoutMs: 8_000,
|
||||
select: event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
if(!parser) return [];
|
||||
|
||||
const combined = [ ...parser.friends, ...parser.others ];
|
||||
const summaries: IHousekeepingUserSummary[] = [];
|
||||
|
||||
for(const entry of combined)
|
||||
{
|
||||
const username = entry.avatarName || '';
|
||||
|
||||
if(!username.toLowerCase().startsWith(prefix.toLowerCase())) continue;
|
||||
|
||||
summaries.push({
|
||||
id: entry.avatarId,
|
||||
username,
|
||||
figure: entry.avatarFigure || '',
|
||||
online: entry.isAvatarOnline === true,
|
||||
rank: 0
|
||||
});
|
||||
|
||||
if(summaries.length >= USER_SEARCH_LIMIT) break;
|
||||
}
|
||||
|
||||
return summaries;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const mapUserDetail = (user: HousekeepingUserDetailData): IHousekeepingUser => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
motto: user.motto,
|
||||
figure: user.figure,
|
||||
rank: user.rank,
|
||||
rankName: user.rankName,
|
||||
online: user.online,
|
||||
lastOnlineAt: user.lastOnlineAt > 0 ? user.lastOnlineAt : null,
|
||||
creditsBalance: user.creditsBalance,
|
||||
ducketsBalance: user.ducketsBalance,
|
||||
diamondsBalance: user.diamondsBalance,
|
||||
email: user.email,
|
||||
ipLast: user.ipLast,
|
||||
isBanned: user.isBanned,
|
||||
isMuted: user.isMuted,
|
||||
isTradeLocked: user.isTradeLocked
|
||||
});
|
||||
|
||||
const awaitUserDetail = (): Promise<IHousekeepingUser | null> =>
|
||||
awaitMessageEvent<HousekeepingUserDetailEvent, IHousekeepingUser | null>(HousekeepingUserDetailEvent, {
|
||||
timeoutMs: 8_000,
|
||||
select: event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
if(!parser || !parser.found || !parser.user) return null;
|
||||
|
||||
return mapUserDetail(parser.user);
|
||||
}
|
||||
});
|
||||
|
||||
const findUserByNameViaPacket = async (username: string): Promise<IHousekeepingUser | null> =>
|
||||
{
|
||||
const trimmed = (username || '').trim();
|
||||
|
||||
if(!trimmed) return null;
|
||||
|
||||
SendMessageComposer(new HousekeepingFindUserByNameComposer(trimmed));
|
||||
|
||||
return awaitUserDetail();
|
||||
};
|
||||
|
||||
const findUserByIdViaPacket = async (userId: number): Promise<IHousekeepingUser | null> =>
|
||||
{
|
||||
if(!Number.isFinite(userId) || userId <= 0) return null;
|
||||
|
||||
SendMessageComposer(new HousekeepingFindUserByIdComposer(userId));
|
||||
|
||||
return awaitUserDetail();
|
||||
};
|
||||
|
||||
/**
|
||||
* Fire any HK action composer and resolve when the matching
|
||||
* HousekeepingActionResultEvent arrives. The server tags each ack with
|
||||
* a string `actionKey` (`user.ban`, `user.mute`, …) so the listener can
|
||||
* filter via the `accept` predicate — protects against another concurrent
|
||||
* action's ack slipping into a waiter that was expecting a different one.
|
||||
*/
|
||||
const runHkAction = async (composer: IMessageComposer<unknown[]>, expectedActionKey: string, timeoutMs = 15_000): Promise<IHousekeepingActionResult> =>
|
||||
{
|
||||
SendMessageComposer(composer);
|
||||
|
||||
try
|
||||
{
|
||||
return await awaitMessageEvent<HousekeepingActionResultEvent, IHousekeepingActionResult>(HousekeepingActionResultEvent, {
|
||||
timeoutMs,
|
||||
accept: e => e.getParser()?.actionKey === expectedActionKey,
|
||||
select: event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
if(!parser) return { ok: false, actionId: null, message: 'no_parser' };
|
||||
|
||||
return {
|
||||
ok: parser.ok,
|
||||
actionId: parser.actionId > 0 ? parser.actionId : null,
|
||||
message: parser.message
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
catch(err)
|
||||
{
|
||||
const reason = err instanceof Error ? err.message : 'unknown';
|
||||
|
||||
return { ok: false, actionId: null, message: reason };
|
||||
}
|
||||
};
|
||||
|
||||
const banUserViaPacket = (userId: number, reason: string, hours: number): Promise<IHousekeepingActionResult> =>
|
||||
runHkAction(new HousekeepingBanUserComposer(userId, reason || '', hours), 'user.ban');
|
||||
|
||||
const unbanUserViaPacket = (userId: number): Promise<IHousekeepingActionResult> =>
|
||||
runHkAction(new HousekeepingUnbanUserComposer(userId), 'user.unban');
|
||||
|
||||
const muteUserViaPacket = (userId: number, reason: string, minutes: number): Promise<IHousekeepingActionResult> =>
|
||||
runHkAction(new HousekeepingMuteUserComposer(userId, reason || '', minutes), 'user.mute');
|
||||
|
||||
const kickUserViaPacket = (userId: number, reason: string): Promise<IHousekeepingActionResult> =>
|
||||
runHkAction(new HousekeepingKickUserComposer(userId, reason || ''), 'user.kick');
|
||||
|
||||
const forceDisconnectUserViaPacket = (userId: number, reason: string): Promise<IHousekeepingActionResult> =>
|
||||
runHkAction(new HousekeepingForceDisconnectUserComposer(userId, reason || ''), 'user.disconnect');
|
||||
|
||||
const setUserRankViaPacket = (userId: number, rank: number): Promise<IHousekeepingActionResult> =>
|
||||
runHkAction(new HousekeepingSetUserRankComposer(userId, rank), 'user.set_rank');
|
||||
|
||||
const tradeLockUserViaPacket = (userId: number, hours: number, reason: string): Promise<IHousekeepingActionResult> =>
|
||||
runHkAction(new HousekeepingTradeLockUserComposer(userId, hours, reason || ''), 'user.trade_lock');
|
||||
|
||||
const resetUserPasswordViaPacket = (userId: number): Promise<IHousekeepingActionResult> =>
|
||||
runHkAction(new HousekeepingResetUserPasswordComposer(userId), 'user.reset_password');
|
||||
|
||||
const mapRoom = (room: HousekeepingRoomData): IHousekeepingRoom => ({
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
description: room.description,
|
||||
ownerId: room.ownerId,
|
||||
ownerName: room.ownerName,
|
||||
userCount: room.userCount,
|
||||
maxUsers: room.maxUsers,
|
||||
isLocked: room.isLocked,
|
||||
isMuted: room.isMuted,
|
||||
isPublic: room.isPublic,
|
||||
createdAt: room.createdAt
|
||||
});
|
||||
|
||||
const findRoomByIdViaPacket = (roomId: number): Promise<IHousekeepingRoom | null> =>
|
||||
{
|
||||
if(!Number.isFinite(roomId) || roomId <= 0) return Promise.resolve(null);
|
||||
|
||||
SendMessageComposer(new HousekeepingFindRoomByIdComposer(roomId));
|
||||
|
||||
return awaitMessageEvent<HousekeepingRoomDetailEvent, IHousekeepingRoom | null>(HousekeepingRoomDetailEvent, {
|
||||
timeoutMs: 8_000,
|
||||
select: event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
if(!parser || !parser.found || !parser.room) return null;
|
||||
|
||||
return mapRoom(parser.room);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const findRoomByNameViaPacket = (name: string): Promise<IHousekeepingRoom[]> =>
|
||||
{
|
||||
const trimmed = (name || '').trim();
|
||||
|
||||
if(!trimmed) return Promise.resolve([]);
|
||||
|
||||
SendMessageComposer(new HousekeepingSearchRoomsComposer(trimmed, true, 50));
|
||||
|
||||
return awaitMessageEvent<HousekeepingRoomListEvent, IHousekeepingRoom[]>(HousekeepingRoomListEvent, {
|
||||
timeoutMs: 8_000,
|
||||
select: event => event.getParser()?.rooms.map(mapRoom) ?? []
|
||||
});
|
||||
};
|
||||
|
||||
const searchRoomsViaPacket = (prefix: string, signal?: AbortSignal): Promise<IHousekeepingRoomSummary[]> =>
|
||||
{
|
||||
const trimmed = (prefix || '').trim();
|
||||
|
||||
if(!trimmed) return Promise.resolve([]);
|
||||
|
||||
SendMessageComposer(new HousekeepingSearchRoomsComposer(trimmed, false, 8));
|
||||
|
||||
return awaitMessageEvent<HousekeepingRoomListEvent, IHousekeepingRoomSummary[]>(HousekeepingRoomListEvent, {
|
||||
signal,
|
||||
timeoutMs: 8_000,
|
||||
select: event => event.getParser()?.rooms.map(room => ({
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
userCount: room.userCount,
|
||||
ownerName: room.ownerName
|
||||
})) ?? []
|
||||
});
|
||||
};
|
||||
|
||||
const setRoomStateViaPacket = (roomId: number, open: boolean): Promise<IHousekeepingActionResult> =>
|
||||
runHkAction(new HousekeepingRoomStateComposer(roomId, open), open ? 'room.open' : 'room.close');
|
||||
|
||||
const muteRoomViaPacket = (roomId: number, minutes: number): Promise<IHousekeepingActionResult> =>
|
||||
runHkAction(new HousekeepingMuteRoomComposer(roomId, minutes), 'room.mute');
|
||||
|
||||
const kickAllFromRoomViaPacket = (roomId: number): Promise<IHousekeepingActionResult> =>
|
||||
runHkAction(new HousekeepingKickAllFromRoomComposer(roomId), 'room.kick_all');
|
||||
|
||||
const transferRoomOwnershipViaPacket = (roomId: number, newOwnerId: number): Promise<IHousekeepingActionResult> =>
|
||||
runHkAction(new HousekeepingTransferRoomOwnershipComposer(roomId, newOwnerId), 'room.transfer');
|
||||
|
||||
const deleteRoomViaPacket = (roomId: number): Promise<IHousekeepingActionResult> =>
|
||||
runHkAction(new HousekeepingDeleteRoomComposer(roomId), 'room.delete');
|
||||
|
||||
const CURRENCY_DUCKETS = 0;
|
||||
const CURRENCY_DIAMONDS = 5;
|
||||
|
||||
const giveCreditsViaPacket = (userId: number, amount: number): Promise<IHousekeepingActionResult> =>
|
||||
runHkAction(new HousekeepingGiveCreditsComposer(userId, amount), 'user.give_credits');
|
||||
|
||||
const giveDucketsViaPacket = (userId: number, amount: number): Promise<IHousekeepingActionResult> =>
|
||||
runHkAction(new HousekeepingGiveCurrencyComposer(userId, CURRENCY_DUCKETS, amount), `user.give_currency_${ CURRENCY_DUCKETS }`);
|
||||
|
||||
const giveDiamondsViaPacket = (userId: number, amount: number): Promise<IHousekeepingActionResult> =>
|
||||
runHkAction(new HousekeepingGiveCurrencyComposer(userId, CURRENCY_DIAMONDS, amount), `user.give_currency_${ CURRENCY_DIAMONDS }`);
|
||||
|
||||
const grantItemViaPacket = (userId: number, itemId: number, quantity: number): Promise<IHousekeepingActionResult> =>
|
||||
runHkAction(new HousekeepingGrantItemComposer(userId, itemId, quantity), 'user.grant_item');
|
||||
|
||||
const setHcSubscriptionViaPacket = (userId: number, days: number): Promise<IHousekeepingActionResult> =>
|
||||
runHkAction(new HousekeepingSetHcSubscriptionComposer(userId, days), 'user.set_hc');
|
||||
|
||||
const sendHotelAlertViaPacket = (message: string): Promise<IHousekeepingActionResult> =>
|
||||
runHkAction(new HousekeepingSendHotelAlertComposer(message || ''), 'hotel.alert');
|
||||
|
||||
const EMPTY_DASHBOARD: IHousekeepingDashboard = {
|
||||
onlineUsers: 0, totalUsers: 0, activeRooms: 0, totalRooms: 0,
|
||||
peakOnlineToday: 0, peakOnlineAllTime: 0, pendingTickets: 0,
|
||||
sanctionsLast24h: 0, serverUptimeSeconds: 0, serverVersion: ''
|
||||
};
|
||||
|
||||
const getDashboardViaPacket = (signal?: AbortSignal): Promise<IHousekeepingDashboard> =>
|
||||
{
|
||||
SendMessageComposer(new HousekeepingGetDashboardComposer());
|
||||
|
||||
return awaitMessageEvent<HousekeepingDashboardEvent, IHousekeepingDashboard>(HousekeepingDashboardEvent, {
|
||||
signal,
|
||||
timeoutMs: 10_000,
|
||||
select: event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
if(!parser) return EMPTY_DASHBOARD;
|
||||
|
||||
return {
|
||||
onlineUsers: parser.onlineUsers,
|
||||
totalUsers: parser.totalUsers,
|
||||
activeRooms: parser.activeRooms,
|
||||
totalRooms: parser.totalRooms,
|
||||
peakOnlineToday: parser.peakOnlineToday,
|
||||
peakOnlineAllTime: parser.peakOnlineAllTime,
|
||||
pendingTickets: parser.pendingTickets,
|
||||
sanctionsLast24h: parser.sanctionsLast24h,
|
||||
serverUptimeSeconds: parser.serverUptimeSeconds,
|
||||
serverVersion: parser.serverVersion
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const listActionLogViaPacket = (limit: number, signal?: AbortSignal): Promise<IHousekeepingActionLogEntry[]> =>
|
||||
{
|
||||
const safeLimit = Math.max(1, Math.min(500, Math.floor(limit || 50)));
|
||||
|
||||
SendMessageComposer(new HousekeepingListActionLogComposer(safeLimit));
|
||||
|
||||
return awaitMessageEvent<HousekeepingActionLogEvent, IHousekeepingActionLogEntry[]>(HousekeepingActionLogEvent, {
|
||||
signal,
|
||||
timeoutMs: 10_000,
|
||||
select: event => event.getParser()?.entries.map(entry => ({
|
||||
id: entry.id,
|
||||
timestamp: entry.timestamp,
|
||||
actorId: entry.actorId,
|
||||
actorName: entry.actorName,
|
||||
targetType: (entry.targetType === 'room' || entry.targetType === 'hotel') ? entry.targetType : 'user',
|
||||
targetId: entry.targetId > 0 ? entry.targetId : null,
|
||||
targetLabel: entry.targetLabel,
|
||||
action: entry.action,
|
||||
detail: entry.detail,
|
||||
success: entry.success
|
||||
})) ?? []
|
||||
});
|
||||
};
|
||||
|
||||
export const HousekeepingApi = {
|
||||
// -- dashboard -------------------------------------------------
|
||||
getDashboard: (signal?: AbortSignal) => getDashboardViaPacket(signal),
|
||||
|
||||
// -- user lookup -----------------------------------------------
|
||||
findUserByName: (username: string) => findUserByNameViaPacket(username),
|
||||
findUserById: (userId: number) => findUserByIdViaPacket(userId),
|
||||
searchUsers: (prefix: string, signal?: AbortSignal) => searchUsersViaPacket(prefix, signal),
|
||||
|
||||
// -- user actions ----------------------------------------------
|
||||
banUser: (userId: number, reason: string, hours: number) => banUserViaPacket(userId, reason, hours),
|
||||
unbanUser: (userId: number) => unbanUserViaPacket(userId),
|
||||
muteUser: (userId: number, reason: string, minutes: number) => muteUserViaPacket(userId, reason, minutes),
|
||||
kickUser: (userId: number, reason: string) => kickUserViaPacket(userId, reason),
|
||||
forceDisconnectUser: (userId: number, reason: string) => forceDisconnectUserViaPacket(userId, reason),
|
||||
resetUserPassword: (userId: number) => resetUserPasswordViaPacket(userId),
|
||||
setUserRank: (userId: number, rank: number) => setUserRankViaPacket(userId, rank),
|
||||
tradeLockUser: (userId: number, hours: number, reason: string) => tradeLockUserViaPacket(userId, hours, reason),
|
||||
|
||||
// -- room lookup -----------------------------------------------
|
||||
findRoomById: (roomId: number) => findRoomByIdViaPacket(roomId),
|
||||
findRoomByName: (name: string) => findRoomByNameViaPacket(name),
|
||||
searchRooms: (prefix: string, signal?: AbortSignal) => searchRoomsViaPacket(prefix, signal),
|
||||
|
||||
// -- room actions ----------------------------------------------
|
||||
openRoom: (roomId: number) => setRoomStateViaPacket(roomId, true),
|
||||
closeRoom: (roomId: number) => setRoomStateViaPacket(roomId, false),
|
||||
muteRoom: (roomId: number, minutes: number) => muteRoomViaPacket(roomId, minutes),
|
||||
kickAllFromRoom: (roomId: number) => kickAllFromRoomViaPacket(roomId),
|
||||
transferRoomOwnership: (roomId: number, newOwnerId: number) => transferRoomOwnershipViaPacket(roomId, newOwnerId),
|
||||
deleteRoom: (roomId: number) => deleteRoomViaPacket(roomId),
|
||||
|
||||
// -- economy actions -------------------------------------------
|
||||
giveCredits: (userId: number, amount: number) => giveCreditsViaPacket(userId, amount),
|
||||
giveDuckets: (userId: number, amount: number) => giveDucketsViaPacket(userId, amount),
|
||||
giveDiamonds: (userId: number, amount: number) => giveDiamondsViaPacket(userId, amount),
|
||||
grantItem: (userId: number, itemId: number, quantity: number) => grantItemViaPacket(userId, itemId, quantity),
|
||||
setHcSubscription: (userId: number, days: number) => setHcSubscriptionViaPacket(userId, days),
|
||||
|
||||
// -- hotel-level -----------------------------------------------
|
||||
sendHotelAlert: (message: string) => sendHotelAlertViaPacket(message),
|
||||
listActionLog: (limit: number, signal?: AbortSignal) => listActionLogViaPacket(limit, signal)
|
||||
} as const;
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { HousekeepingTabId } from './HousekeepingActionType';
|
||||
import { housekeepingTabsForMode, isHousekeepingTabAvailable, resolveHousekeepingMode } from './HousekeepingConfig';
|
||||
|
||||
describe('resolveHousekeepingMode', () =>
|
||||
{
|
||||
it('returns "light" only for the exact "light" string', () =>
|
||||
{
|
||||
expect(resolveHousekeepingMode('light')).toBe('light');
|
||||
});
|
||||
|
||||
it('falls back to "full" for any other value (unknown strings, typos, non-strings)', () =>
|
||||
{
|
||||
expect(resolveHousekeepingMode('full')).toBe('full');
|
||||
expect(resolveHousekeepingMode('FULL')).toBe('full');
|
||||
expect(resolveHousekeepingMode('Light')).toBe('full');
|
||||
expect(resolveHousekeepingMode('')).toBe('full');
|
||||
expect(resolveHousekeepingMode(undefined)).toBe('full');
|
||||
expect(resolveHousekeepingMode(null)).toBe('full');
|
||||
expect(resolveHousekeepingMode(42)).toBe('full');
|
||||
expect(resolveHousekeepingMode({})).toBe('full');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isHousekeepingTabAvailable', () =>
|
||||
{
|
||||
it('exposes every tab in full mode', () =>
|
||||
{
|
||||
expect(isHousekeepingTabAvailable(HousekeepingTabId.DASHBOARD, 'full')).toBe(true);
|
||||
expect(isHousekeepingTabAvailable(HousekeepingTabId.USERS, 'full')).toBe(true);
|
||||
expect(isHousekeepingTabAvailable(HousekeepingTabId.ROOMS, 'full')).toBe(true);
|
||||
expect(isHousekeepingTabAvailable(HousekeepingTabId.ECONOMY, 'full')).toBe(true);
|
||||
expect(isHousekeepingTabAvailable(HousekeepingTabId.AUDIT, 'full')).toBe(true);
|
||||
});
|
||||
|
||||
it('exposes only Users + Rooms in light mode', () =>
|
||||
{
|
||||
expect(isHousekeepingTabAvailable(HousekeepingTabId.USERS, 'light')).toBe(true);
|
||||
expect(isHousekeepingTabAvailable(HousekeepingTabId.ROOMS, 'light')).toBe(true);
|
||||
|
||||
expect(isHousekeepingTabAvailable(HousekeepingTabId.DASHBOARD, 'light')).toBe(false);
|
||||
expect(isHousekeepingTabAvailable(HousekeepingTabId.ECONOMY, 'light')).toBe(false);
|
||||
expect(isHousekeepingTabAvailable(HousekeepingTabId.AUDIT, 'light')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('housekeepingTabsForMode', () =>
|
||||
{
|
||||
it('returns the full ordered tab list in full mode', () =>
|
||||
{
|
||||
expect(housekeepingTabsForMode('full')).toEqual([
|
||||
HousekeepingTabId.DASHBOARD,
|
||||
HousekeepingTabId.USERS,
|
||||
HousekeepingTabId.ROOMS,
|
||||
HousekeepingTabId.ECONOMY,
|
||||
HousekeepingTabId.AUDIT
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns Users + Rooms (in that order) for light mode', () =>
|
||||
{
|
||||
expect(housekeepingTabsForMode('light')).toEqual([
|
||||
HousekeepingTabId.USERS,
|
||||
HousekeepingTabId.ROOMS
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { GetConfigurationValue } from '../nitro';
|
||||
import { HousekeepingTabId } from './HousekeepingActionType';
|
||||
|
||||
export type HousekeepingMode = 'light' | 'full';
|
||||
|
||||
export const HOUSEKEEPING_ENABLED_KEY = 'housekeeping.enabled';
|
||||
export const HOUSEKEEPING_MODE_KEY = 'housekeeping.mode';
|
||||
|
||||
/**
|
||||
* Default-off master switch. When false, the HK module is completely
|
||||
* hidden: no toolbar icon, no panel mount, no link-event routing.
|
||||
* Layered ON TOP of the `acc_housekeeping` permission gate — config
|
||||
* lets the operator disable HK at the build/deploy level even when
|
||||
* the permission exists on the server.
|
||||
*/
|
||||
export const isHousekeepingEnabled = (): boolean =>
|
||||
GetConfigurationValue<boolean>(HOUSEKEEPING_ENABLED_KEY, false) === true;
|
||||
|
||||
/**
|
||||
* `full` (default) exposes the five-tab layout: dashboard, users,
|
||||
* rooms, economy, audit. `light` strips the panel down to the
|
||||
* essentials — Users + Rooms only — for operators who want the
|
||||
* in-client HK only for live moderation, not for economy
|
||||
* management. Anything else than `'light'` resolves to `'full'`
|
||||
* so a typo doesn't quietly hide tabs.
|
||||
*/
|
||||
export const resolveHousekeepingMode = (raw: unknown): HousekeepingMode =>
|
||||
(raw === 'light') ? 'light' : 'full';
|
||||
|
||||
export const getHousekeepingMode = (): HousekeepingMode =>
|
||||
resolveHousekeepingMode(GetConfigurationValue<string>(HOUSEKEEPING_MODE_KEY, 'full'));
|
||||
|
||||
const LIGHT_TABS: ReadonlySet<HousekeepingTabId> = new Set<HousekeepingTabId>([
|
||||
HousekeepingTabId.USERS,
|
||||
HousekeepingTabId.ROOMS
|
||||
]);
|
||||
|
||||
/**
|
||||
* Pure tab-availability check. Kept side-effect-free so tab list
|
||||
* filtering and toolbar / link-event gating can all read the same
|
||||
* source of truth without hitting the config layer multiple times.
|
||||
*/
|
||||
export const isHousekeepingTabAvailable = (tab: HousekeepingTabId, mode: HousekeepingMode): boolean =>
|
||||
{
|
||||
if(mode === 'full') return true;
|
||||
|
||||
return LIGHT_TABS.has(tab);
|
||||
};
|
||||
|
||||
export const housekeepingTabsForMode = (mode: HousekeepingMode): HousekeepingTabId[] =>
|
||||
{
|
||||
const all: HousekeepingTabId[] = [
|
||||
HousekeepingTabId.DASHBOARD,
|
||||
HousekeepingTabId.USERS,
|
||||
HousekeepingTabId.ROOMS,
|
||||
HousekeepingTabId.ECONOMY,
|
||||
HousekeepingTabId.AUDIT
|
||||
];
|
||||
|
||||
return all.filter(tab => isHousekeepingTabAvailable(tab, mode));
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { formatCompactNumber, formatRelativePast, formatUptime } from './HousekeepingFormatters';
|
||||
|
||||
describe('formatUptime', () =>
|
||||
{
|
||||
it('renders 0/negative/NaN/Infinity as "—"', () =>
|
||||
{
|
||||
expect(formatUptime(-1)).toBe('—');
|
||||
expect(formatUptime(NaN)).toBe('—');
|
||||
expect(formatUptime(Infinity)).toBe('—');
|
||||
});
|
||||
|
||||
it('renders seconds only for the fresh-boot case', () =>
|
||||
{
|
||||
expect(formatUptime(0)).toBe('0s');
|
||||
expect(formatUptime(45)).toBe('45s');
|
||||
});
|
||||
|
||||
it('renders minutes (no hour part) when below 1h', () =>
|
||||
{
|
||||
expect(formatUptime(60)).toBe('1m');
|
||||
expect(formatUptime(60 * 59)).toBe('59m');
|
||||
});
|
||||
|
||||
it('renders hours + minutes when below 1 day', () =>
|
||||
{
|
||||
expect(formatUptime(60 * 60)).toBe('1h 0m');
|
||||
expect(formatUptime((60 * 60 * 5) + (60 * 12))).toBe('5h 12m');
|
||||
});
|
||||
|
||||
it('renders days + hours + minutes when over a day', () =>
|
||||
{
|
||||
const fiveDaysTwelveHoursThreeMinutes = (5 * 86400) + (12 * 3600) + (3 * 60);
|
||||
|
||||
expect(formatUptime(fiveDaysTwelveHoursThreeMinutes)).toBe('5d 12h 3m');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatRelativePast', () =>
|
||||
{
|
||||
const NOW = 1_700_000_000_000; // fixed reference
|
||||
|
||||
it('renders "—" for invalid input', () =>
|
||||
{
|
||||
expect(formatRelativePast(0, NOW)).toBe('—');
|
||||
expect(formatRelativePast(-100, NOW)).toBe('—');
|
||||
expect(formatRelativePast(NaN, NOW)).toBe('—');
|
||||
});
|
||||
|
||||
it('renders "now" for the first 5 seconds', () =>
|
||||
{
|
||||
expect(formatRelativePast(NOW - 1_000, NOW)).toBe('now');
|
||||
expect(formatRelativePast(NOW - 4_000, NOW)).toBe('now');
|
||||
});
|
||||
|
||||
it('renders seconds-ago between 5s and 1m', () =>
|
||||
{
|
||||
expect(formatRelativePast(NOW - 10_000, NOW)).toBe('10s ago');
|
||||
expect(formatRelativePast(NOW - 59_000, NOW)).toBe('59s ago');
|
||||
});
|
||||
|
||||
it('renders minutes / hours / days as we cross each unit boundary', () =>
|
||||
{
|
||||
expect(formatRelativePast(NOW - (60 * 1000), NOW)).toBe('1m ago');
|
||||
expect(formatRelativePast(NOW - (3600 * 1000), NOW)).toBe('1h ago');
|
||||
expect(formatRelativePast(NOW - (86_400 * 1000), NOW)).toBe('1d ago');
|
||||
expect(formatRelativePast(NOW - (3 * 86_400 * 1000), NOW)).toBe('3d ago');
|
||||
});
|
||||
|
||||
it('switches to a fixed ISO-date prefix beyond 7 days', () =>
|
||||
{
|
||||
const tenDaysAgoMs = NOW - (10 * 86_400 * 1000);
|
||||
const expected = new Date(tenDaysAgoMs).toISOString().slice(0, 10);
|
||||
|
||||
expect(formatRelativePast(tenDaysAgoMs, NOW)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCompactNumber', () =>
|
||||
{
|
||||
it('returns "—" for non-finite input', () =>
|
||||
{
|
||||
expect(formatCompactNumber(NaN)).toBe('—');
|
||||
expect(formatCompactNumber(Infinity)).toBe('—');
|
||||
});
|
||||
|
||||
it('passes through small values', () =>
|
||||
{
|
||||
expect(formatCompactNumber(0)).toBe('0');
|
||||
expect(formatCompactNumber(42)).toBe('42');
|
||||
expect(formatCompactNumber(999)).toBe('999');
|
||||
});
|
||||
|
||||
it('uses K from 1_000 onwards (drops decimals at 10K+ for readability)', () =>
|
||||
{
|
||||
expect(formatCompactNumber(1_000)).toBe('1.0K');
|
||||
expect(formatCompactNumber(1_500)).toBe('1.5K');
|
||||
expect(formatCompactNumber(12_345)).toBe('12K');
|
||||
});
|
||||
|
||||
it('uses M from 1_000_000 onwards (drops decimals at 10M+)', () =>
|
||||
{
|
||||
expect(formatCompactNumber(1_000_000)).toBe('1.0M');
|
||||
expect(formatCompactNumber(2_300_000)).toBe('2.3M');
|
||||
expect(formatCompactNumber(15_000_000)).toBe('15M');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
const SECOND = 1;
|
||||
const MINUTE = 60 * SECOND;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
/**
|
||||
* "5d 12h 3m" — compact uptime display for the dashboard. We don't
|
||||
* use the existing `friendlyTime` helper because that one is tuned
|
||||
* for "how long ago" (past tense, single-unit), while uptime needs
|
||||
* multi-unit forward-looking output and to handle seconds-only
|
||||
* fresh-boot cases.
|
||||
*/
|
||||
export const formatUptime = (seconds: number): string =>
|
||||
{
|
||||
if(!Number.isFinite(seconds) || seconds < 0) return '—';
|
||||
if(seconds < MINUTE) return `${ Math.floor(seconds) }s`;
|
||||
|
||||
const d = Math.floor(seconds / DAY);
|
||||
const h = Math.floor((seconds % DAY) / HOUR);
|
||||
const m = Math.floor((seconds % HOUR) / MINUTE);
|
||||
|
||||
if(d > 0) return `${ d }d ${ h }h ${ m }m`;
|
||||
if(h > 0) return `${ h }h ${ m }m`;
|
||||
|
||||
return `${ m }m`;
|
||||
};
|
||||
|
||||
/**
|
||||
* "5m ago", "2h ago", "3d ago" — past-tense relative formatter for
|
||||
* audit-log timestamps. Anything older than a day rolls to a fixed
|
||||
* date string so the log entries stay scannable even after a week.
|
||||
*/
|
||||
export const formatRelativePast = (timestampMs: number, nowMs: number = Date.now()): string =>
|
||||
{
|
||||
if(!Number.isFinite(timestampMs) || timestampMs <= 0) return '—';
|
||||
|
||||
const deltaSeconds = Math.max(0, Math.floor((nowMs - timestampMs) / 1000));
|
||||
|
||||
if(deltaSeconds < 5) return 'now';
|
||||
if(deltaSeconds < MINUTE) return `${ deltaSeconds }s ago`;
|
||||
if(deltaSeconds < HOUR) return `${ Math.floor(deltaSeconds / MINUTE) }m ago`;
|
||||
if(deltaSeconds < DAY) return `${ Math.floor(deltaSeconds / HOUR) }h ago`;
|
||||
if(deltaSeconds < 7 * DAY) return `${ Math.floor(deltaSeconds / DAY) }d ago`;
|
||||
|
||||
const date = new Date(timestampMs);
|
||||
|
||||
return date.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
export const formatCompactNumber = (value: number): string =>
|
||||
{
|
||||
if(!Number.isFinite(value)) return '—';
|
||||
|
||||
const abs = Math.abs(value);
|
||||
|
||||
if(abs >= 1_000_000) return `${ (value / 1_000_000).toFixed(abs >= 10_000_000 ? 0 : 1) }M`;
|
||||
if(abs >= 1_000) return `${ (value / 1_000).toFixed(abs >= 10_000 ? 0 : 1) }K`;
|
||||
|
||||
return value.toString();
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
import { getAccessToken } from '../auth/accessToken';
|
||||
|
||||
const trimSlash = (value: string) => value.replace(/\/$/, '');
|
||||
|
||||
const resolveBaseUrl = (): string =>
|
||||
{
|
||||
const mode = (window as any).NitroClientMode;
|
||||
|
||||
if(mode && typeof mode.apiBaseUrl === 'string' && mode.apiBaseUrl.length) return trimSlash(mode.apiBaseUrl);
|
||||
|
||||
const configured = (window as any).NitroSecureApiUrl;
|
||||
|
||||
if(typeof configured === 'string' && configured.length) return trimSlash(configured);
|
||||
|
||||
return trimSlash(window.location.origin);
|
||||
};
|
||||
|
||||
const buildUrl = (path: string): string =>
|
||||
{
|
||||
const base = resolveBaseUrl();
|
||||
const normalized = path.startsWith('/') ? path : `/${ path }`;
|
||||
|
||||
return `${ base }${ normalized }`;
|
||||
};
|
||||
|
||||
const authHeader = (): Record<string, string> =>
|
||||
{
|
||||
const token = getAccessToken();
|
||||
|
||||
return token ? { Authorization: `Bearer ${ token }` } : {};
|
||||
};
|
||||
|
||||
export interface HousekeepingRequestInit
|
||||
{
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||
body?: unknown;
|
||||
query?: Record<string, string | number | boolean | undefined | null>;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
const appendQuery = (url: string, query?: HousekeepingRequestInit['query']): string =>
|
||||
{
|
||||
if(!query) return url;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
|
||||
for(const [ key, value ] of Object.entries(query))
|
||||
{
|
||||
if(value === undefined || value === null) continue;
|
||||
|
||||
params.set(key, String(value));
|
||||
}
|
||||
|
||||
const qs = params.toString();
|
||||
|
||||
return qs.length ? `${ url }?${ qs }` : url;
|
||||
};
|
||||
|
||||
/**
|
||||
* Thin HTTP wrapper for the admin/housekeeping endpoints. Backed by the
|
||||
* same `apiBaseUrl` the secure-asset layer uses, with the user's
|
||||
* persisted access token attached as a bearer.
|
||||
*
|
||||
* Server is expected to expose REST endpoints under
|
||||
* `${apiBaseUrl}/api/housekeeping/...`. The shape mirrors what
|
||||
* Arcturus-style admin panels already publish, so a server-side
|
||||
* implementation is incremental rather than greenfield.
|
||||
*/
|
||||
export const housekeepingFetch = async <T = unknown>(path: string, init: HousekeepingRequestInit = {}): Promise<T> =>
|
||||
{
|
||||
const { method = 'GET', body = undefined, query = undefined, signal = undefined } = init;
|
||||
const url = appendQuery(buildUrl(path), query);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
...authHeader()
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if(!response.ok)
|
||||
{
|
||||
let detail = '';
|
||||
|
||||
try
|
||||
{
|
||||
const text = await response.text();
|
||||
detail = text || '';
|
||||
}
|
||||
catch
|
||||
{}
|
||||
|
||||
throw new HousekeepingHttpError(response.status, response.statusText, detail, url);
|
||||
}
|
||||
|
||||
if(response.status === 204) return undefined;
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
|
||||
if(!contentType.includes('application/json')) return undefined;
|
||||
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export class HousekeepingHttpError extends Error
|
||||
{
|
||||
public readonly status: number;
|
||||
public readonly statusText: string;
|
||||
public readonly detail: string;
|
||||
public readonly url: string;
|
||||
|
||||
constructor(status: number, statusText: string, detail: string, url: string)
|
||||
{
|
||||
super(`HK HTTP ${ status } ${ statusText }: ${ detail || url }`);
|
||||
this.name = 'HousekeepingHttpError';
|
||||
this.status = status;
|
||||
this.statusText = statusText;
|
||||
this.detail = detail;
|
||||
this.url = url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { readFileSync, readdirSync, statSync } from 'fs';
|
||||
import { join, resolve } from 'path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const ROOT = resolve(__dirname, '..', '..', '..');
|
||||
const EN_PATH = join(ROOT, 'public', 'configuration', 'housekeeping-texts-en.example');
|
||||
const IT_PATH = join(ROOT, 'public', 'configuration', 'housekeeping-texts-it.example');
|
||||
|
||||
const loadDict = (path: string): Record<string, string> =>
|
||||
{
|
||||
const raw = readFileSync(path, 'utf8');
|
||||
|
||||
return JSON.parse(raw);
|
||||
};
|
||||
|
||||
// Walk every .ts/.tsx file under src/ and extract every quoted
|
||||
// `housekeeping.<...>` literal. Doesn't catch fully dynamic keys
|
||||
// (e.g. `housekeeping.validation.${ k }`), so we hand-extend the
|
||||
// expected set with the dynamic prefixes covered in code.
|
||||
const collectReferencedKeys = (): Set<string> =>
|
||||
{
|
||||
const sources: string[] = [];
|
||||
|
||||
const walk = (dir: string) =>
|
||||
{
|
||||
for(const entry of readdirSync(dir))
|
||||
{
|
||||
if(entry.startsWith('.') || entry === 'node_modules') continue;
|
||||
|
||||
const full = join(dir, entry);
|
||||
const stat = statSync(full);
|
||||
|
||||
if(stat.isDirectory()) walk(full);
|
||||
else if(entry.endsWith('.ts') || entry.endsWith('.tsx')) sources.push(full);
|
||||
}
|
||||
};
|
||||
|
||||
walk(join(ROOT, 'src'));
|
||||
|
||||
const keys = new Set<string>();
|
||||
|
||||
for(const source of sources)
|
||||
{
|
||||
const content = readFileSync(source, 'utf8');
|
||||
const matches = content.match(/['"`]housekeeping\.[a-z0-9._]+['"`]/g) || [];
|
||||
|
||||
for(const m of matches)
|
||||
{
|
||||
const cleaned = m.slice(1, -1);
|
||||
|
||||
// Skip config keys (they live in renderer config, not in
|
||||
// the localization dict).
|
||||
const CONFIG_KEYS = new Set([
|
||||
'housekeeping.enabled',
|
||||
'housekeeping.mode',
|
||||
'housekeeping.telemetry.enabled',
|
||||
'housekeeping.audit.poll_interval_ms'
|
||||
]);
|
||||
|
||||
if(CONFIG_KEYS.has(cleaned)) continue;
|
||||
|
||||
keys.add(cleaned);
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
};
|
||||
|
||||
describe('housekeeping i18n dictionaries', () =>
|
||||
{
|
||||
it('EN parses as valid JSON', () =>
|
||||
{
|
||||
expect(() => loadDict(EN_PATH)).not.toThrow();
|
||||
});
|
||||
|
||||
it('IT parses as valid JSON', () =>
|
||||
{
|
||||
expect(() => loadDict(IT_PATH)).not.toThrow();
|
||||
});
|
||||
|
||||
it('EN and IT share the exact same key set (no missing translations on either side)', () =>
|
||||
{
|
||||
const en = loadDict(EN_PATH);
|
||||
const it = loadDict(IT_PATH);
|
||||
const enKeys = new Set(Object.keys(en));
|
||||
const itKeys = new Set(Object.keys(it));
|
||||
|
||||
const missingInIt = [ ...enKeys ].filter(k => !itKeys.has(k));
|
||||
const missingInEn = [ ...itKeys ].filter(k => !enKeys.has(k));
|
||||
|
||||
expect(missingInIt).toEqual([]);
|
||||
expect(missingInEn).toEqual([]);
|
||||
});
|
||||
|
||||
it('every value is a non-empty string in both dicts', () =>
|
||||
{
|
||||
for(const path of [ EN_PATH, IT_PATH ])
|
||||
{
|
||||
const dict = loadDict(path);
|
||||
|
||||
for(const [ key, value ] of Object.entries(dict))
|
||||
{
|
||||
expect(typeof value).toBe('string');
|
||||
expect(value.length).toBeGreaterThan(0);
|
||||
expect(key.startsWith('housekeeping.')).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('EN covers every static `housekeeping.*` key referenced in source code', () =>
|
||||
{
|
||||
const en = loadDict(EN_PATH);
|
||||
const enKeys = new Set(Object.keys(en));
|
||||
const referenced = collectReferencedKeys();
|
||||
|
||||
const uncovered = [ ...referenced ].filter(key => !enKeys.has(key));
|
||||
|
||||
expect(uncovered).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { emptySample, HK_METRICS_SAMPLE_CAP, recordSample, sampleToMetric } from './HousekeepingMetrics';
|
||||
|
||||
describe('emptySample', () =>
|
||||
{
|
||||
it('starts with zero samples and counts', () =>
|
||||
{
|
||||
const e = emptySample();
|
||||
|
||||
expect(e.samples).toEqual([]);
|
||||
expect(e.count).toBe(0);
|
||||
expect(e.errors).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordSample', () =>
|
||||
{
|
||||
it('appends one sample and increments count', () =>
|
||||
{
|
||||
const next = recordSample(emptySample(), 50, false);
|
||||
|
||||
expect(next.samples).toEqual([ 50 ]);
|
||||
expect(next.count).toBe(1);
|
||||
expect(next.errors).toBe(0);
|
||||
});
|
||||
|
||||
it('tracks errors independently from total count', () =>
|
||||
{
|
||||
let s = emptySample();
|
||||
s = recordSample(s, 10, false);
|
||||
s = recordSample(s, 20, true);
|
||||
s = recordSample(s, 30, false);
|
||||
|
||||
expect(s.count).toBe(3);
|
||||
expect(s.errors).toBe(1);
|
||||
});
|
||||
|
||||
it('never mutates the input (returns a new sample object)', () =>
|
||||
{
|
||||
const before = emptySample();
|
||||
const after = recordSample(before, 100, false);
|
||||
|
||||
expect(before.samples).toEqual([]);
|
||||
expect(after).not.toBe(before);
|
||||
});
|
||||
|
||||
it('trims the sliding window to SAMPLE_CAP, keeping the most recent values', () =>
|
||||
{
|
||||
let s = emptySample();
|
||||
|
||||
// Push CAP+5 samples so the first 5 should fall off.
|
||||
for(let i = 0; i < HK_METRICS_SAMPLE_CAP + 5; i++) s = recordSample(s, i, false);
|
||||
|
||||
expect(s.samples.length).toBe(HK_METRICS_SAMPLE_CAP);
|
||||
// Most-recent sample (i = CAP+4) survives
|
||||
expect(s.samples[s.samples.length - 1]).toBe(HK_METRICS_SAMPLE_CAP + 4);
|
||||
// First 5 values (0..4) dropped — sample[0] now starts at 5
|
||||
expect(s.samples[0]).toBe(5);
|
||||
// Count keeps growing past the cap (cumulative, NOT windowed)
|
||||
expect(s.count).toBe(HK_METRICS_SAMPLE_CAP + 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sampleToMetric', () =>
|
||||
{
|
||||
it('returns zeros for an empty sample (no samples observed yet)', () =>
|
||||
{
|
||||
const m = sampleToMetric('ban', emptySample());
|
||||
|
||||
expect(m).toEqual({
|
||||
action: 'ban',
|
||||
count: 0,
|
||||
errors: 0,
|
||||
lastMs: 0,
|
||||
minMs: 0,
|
||||
maxMs: 0,
|
||||
p50Ms: 0,
|
||||
p95Ms: 0
|
||||
});
|
||||
});
|
||||
|
||||
it('handles a single sample (P50 == P95 == min == max == lastMs)', () =>
|
||||
{
|
||||
let s = emptySample();
|
||||
s = recordSample(s, 42, false);
|
||||
const m = sampleToMetric('kick', s);
|
||||
|
||||
expect(m.lastMs).toBe(42);
|
||||
expect(m.minMs).toBe(42);
|
||||
expect(m.maxMs).toBe(42);
|
||||
expect(m.p50Ms).toBe(42);
|
||||
expect(m.p95Ms).toBe(42);
|
||||
});
|
||||
|
||||
it('computes P50 and P95 on a sorted copy (input order does not affect output)', () =>
|
||||
{
|
||||
// Build a known 11-sample distribution: 0..100 in steps of 10.
|
||||
let s = emptySample();
|
||||
const values = [ 100, 10, 50, 30, 80, 0, 70, 20, 90, 40, 60 ];
|
||||
|
||||
for(const v of values) s = recordSample(s, v, false);
|
||||
|
||||
const m = sampleToMetric('mute', s);
|
||||
|
||||
// With 11 samples sorted 0..100, P50 = 50 (median index 5),
|
||||
// P95 = 95 (between sorted[9]=90 and sorted[10]=100, half-way).
|
||||
expect(m.p50Ms).toBe(50);
|
||||
expect(m.p95Ms).toBeCloseTo(95, 1);
|
||||
expect(m.minMs).toBe(0);
|
||||
expect(m.maxMs).toBe(100);
|
||||
expect(m.lastMs).toBe(60); // last pushed value
|
||||
expect(m.count).toBe(11);
|
||||
});
|
||||
|
||||
it('preserves the error count in the snapshot', () =>
|
||||
{
|
||||
let s = emptySample();
|
||||
s = recordSample(s, 10, true);
|
||||
s = recordSample(s, 20, true);
|
||||
s = recordSample(s, 30, false);
|
||||
|
||||
const m = sampleToMetric('ban', s);
|
||||
|
||||
expect(m.count).toBe(3);
|
||||
expect(m.errors).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Per-action metrics — bounded sliding window of latency samples,
|
||||
* P50/P95 computed on demand. Keep this pure so the action runner
|
||||
* (`useHousekeepingActions.runAction`) and the debug panel render
|
||||
* function can both read the same shape without re-implementing
|
||||
* percentile math.
|
||||
*/
|
||||
|
||||
export interface HousekeepingActionMetric
|
||||
{
|
||||
action: string;
|
||||
/** Total calls observed (success + failure). */
|
||||
count: number;
|
||||
/** Failures only — `result.ok === false` or thrown. */
|
||||
errors: number;
|
||||
/** Most-recent latency in ms, plus min/max for visibility. */
|
||||
lastMs: number;
|
||||
minMs: number;
|
||||
maxMs: number;
|
||||
p50Ms: number;
|
||||
p95Ms: number;
|
||||
}
|
||||
|
||||
const SAMPLE_CAP = 50;
|
||||
|
||||
const percentile = (sorted: ReadonlyArray<number>, p: number): number =>
|
||||
{
|
||||
if(sorted.length === 0) return 0;
|
||||
if(sorted.length === 1) return sorted[0];
|
||||
|
||||
// Linear interpolation between adjacent samples — standard
|
||||
// percentile definition. Clamp the rank into [0, n-1] so p=100
|
||||
// doesn't read off the end on small samples.
|
||||
const rank = (p / 100) * (sorted.length - 1);
|
||||
const lo = Math.floor(rank);
|
||||
const hi = Math.ceil(rank);
|
||||
|
||||
if(lo === hi) return sorted[lo];
|
||||
|
||||
const frac = rank - lo;
|
||||
|
||||
return (sorted[lo] * (1 - frac)) + (sorted[hi] * frac);
|
||||
};
|
||||
|
||||
export interface MetricSample
|
||||
{
|
||||
samples: number[];
|
||||
count: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
export const emptySample = (): MetricSample => ({ samples: [], count: 0, errors: 0 });
|
||||
|
||||
/**
|
||||
* Append a new latency sample, trim past SAMPLE_CAP. Returns a NEW
|
||||
* object so the shape plays nicely with React state updates — never
|
||||
* mutates the input.
|
||||
*/
|
||||
export const recordSample = (current: MetricSample, latencyMs: number, isError: boolean): MetricSample =>
|
||||
{
|
||||
const trimmed = current.samples.length >= SAMPLE_CAP
|
||||
? current.samples.slice(current.samples.length - (SAMPLE_CAP - 1))
|
||||
: current.samples.slice();
|
||||
|
||||
trimmed.push(latencyMs);
|
||||
|
||||
return {
|
||||
samples: trimmed,
|
||||
count: current.count + 1,
|
||||
errors: current.errors + (isError ? 1 : 0)
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Snapshot transform — fold the sliding window into a renderable
|
||||
* record. Computes percentiles on a sorted copy (small `samples`
|
||||
* sizes — cap is 50, so this is essentially O(n log n) on n≤50).
|
||||
*/
|
||||
export const sampleToMetric = (action: string, sample: MetricSample): HousekeepingActionMetric =>
|
||||
{
|
||||
if(sample.samples.length === 0)
|
||||
{
|
||||
return {
|
||||
action,
|
||||
count: sample.count,
|
||||
errors: sample.errors,
|
||||
lastMs: 0,
|
||||
minMs: 0,
|
||||
maxMs: 0,
|
||||
p50Ms: 0,
|
||||
p95Ms: 0
|
||||
};
|
||||
}
|
||||
|
||||
const sorted = sample.samples.slice().sort((a, b) => a - b);
|
||||
|
||||
return {
|
||||
action,
|
||||
count: sample.count,
|
||||
errors: sample.errors,
|
||||
lastMs: sample.samples[sample.samples.length - 1],
|
||||
minMs: sorted[0],
|
||||
maxMs: sorted[sorted.length - 1],
|
||||
p50Ms: percentile(sorted, 50),
|
||||
p95Ms: percentile(sorted, 95)
|
||||
};
|
||||
};
|
||||
|
||||
export const HK_METRICS_SAMPLE_CAP = SAMPLE_CAP;
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { pushRecentLookup, RECENT_LOOKUPS_LIMIT, RecentLookupEntry } from './HousekeepingRecentLookups';
|
||||
|
||||
const entry = (over: Partial<RecentLookupEntry> = {}): RecentLookupEntry => ({
|
||||
kind: 'user',
|
||||
id: 1,
|
||||
label: 'alice',
|
||||
at: 1,
|
||||
...over
|
||||
});
|
||||
|
||||
describe('pushRecentLookup', () =>
|
||||
{
|
||||
it('prepends a new entry to an empty list', () =>
|
||||
{
|
||||
const next = pushRecentLookup([], entry({ id: 7, label: 'bob' }));
|
||||
|
||||
expect(next).toHaveLength(1);
|
||||
expect(next[0].id).toBe(7);
|
||||
});
|
||||
|
||||
it('moves an existing entry of the same kind+id to the front (and refreshes the timestamp)', () =>
|
||||
{
|
||||
const initial: RecentLookupEntry[] = [
|
||||
entry({ kind: 'user', id: 1, label: 'alice', at: 1 }),
|
||||
entry({ kind: 'user', id: 2, label: 'bob', at: 2 })
|
||||
];
|
||||
const next = pushRecentLookup(initial, entry({ kind: 'user', id: 2, label: 'bob', at: 99 }));
|
||||
|
||||
expect(next.map(e => e.id)).toEqual([ 2, 1 ]);
|
||||
expect(next[0].at).toBe(99);
|
||||
});
|
||||
|
||||
it('does NOT dedupe across kinds (user #1 and room #1 are distinct)', () =>
|
||||
{
|
||||
const next = pushRecentLookup(
|
||||
[ entry({ kind: 'user', id: 1 }) ],
|
||||
entry({ kind: 'room', id: 1, label: 'lobby' })
|
||||
);
|
||||
|
||||
expect(next).toHaveLength(2);
|
||||
expect(next[0].kind).toBe('room');
|
||||
expect(next[1].kind).toBe('user');
|
||||
});
|
||||
|
||||
it('trims past the limit by dropping the tail entry (caller invariant: newest at index 0, oldest at the end)', () =>
|
||||
{
|
||||
// Build the initial list in store-order: index 0 is the most-recently-pushed
|
||||
// entry, index N-1 is the oldest. id=1 has the FRESHEST `at`, id=N has the OLDEST.
|
||||
const initial: RecentLookupEntry[] = Array.from({ length: RECENT_LOOKUPS_LIMIT }, (_, i) =>
|
||||
entry({ kind: 'user', id: i + 1, label: `u${ i + 1 }`, at: RECENT_LOOKUPS_LIMIT - i })
|
||||
);
|
||||
const tailId = initial[initial.length - 1].id;
|
||||
const next = pushRecentLookup(initial, entry({ kind: 'user', id: 999, label: 'new', at: 1000 }));
|
||||
|
||||
expect(next).toHaveLength(RECENT_LOOKUPS_LIMIT);
|
||||
expect(next[0].id).toBe(999);
|
||||
// The tail entry (the oldest, by store invariant) is the one that falls off
|
||||
expect(next.find(e => e.id === tailId)).toBeUndefined();
|
||||
// The head of the previous list is still around, now at index 1
|
||||
expect(next[1].id).toBe(initial[0].id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
const STORAGE_KEY = 'nitro.housekeeping.recent';
|
||||
const MAX_ENTRIES = 8;
|
||||
|
||||
export interface RecentLookupEntry
|
||||
{
|
||||
kind: 'user' | 'room';
|
||||
id: number;
|
||||
label: string;
|
||||
at: number;
|
||||
}
|
||||
|
||||
const isEntry = (value: unknown): value is RecentLookupEntry =>
|
||||
{
|
||||
if(!value || typeof value !== 'object') return false;
|
||||
|
||||
const obj = value as Record<string, unknown>;
|
||||
|
||||
return (
|
||||
(obj.kind === 'user' || obj.kind === 'room') &&
|
||||
Number.isFinite(obj.id) &&
|
||||
typeof obj.label === 'string' &&
|
||||
Number.isFinite(obj.at)
|
||||
);
|
||||
};
|
||||
|
||||
const readStore = (): RecentLookupEntry[] =>
|
||||
{
|
||||
try
|
||||
{
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
|
||||
if(!raw) return [];
|
||||
|
||||
const parsed = JSON.parse(raw);
|
||||
|
||||
if(!Array.isArray(parsed)) return [];
|
||||
|
||||
return parsed.filter(isEntry);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const writeStore = (entries: RecentLookupEntry[]): void =>
|
||||
{
|
||||
try
|
||||
{
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
|
||||
}
|
||||
catch
|
||||
{}
|
||||
};
|
||||
|
||||
export const loadRecentLookups = (): RecentLookupEntry[] => readStore();
|
||||
|
||||
/**
|
||||
* Push an entry to the front of the recent-lookups stack. Existing
|
||||
* entries with the same kind+id are deduped (so reopening the same
|
||||
* user doesn't bury fresher entries), and the list is trimmed to
|
||||
* MAX_ENTRIES. Pure for the in-memory transform — the persistence is
|
||||
* a side effect on top.
|
||||
*/
|
||||
export const pushRecentLookup = (current: RecentLookupEntry[], entry: RecentLookupEntry): RecentLookupEntry[] =>
|
||||
{
|
||||
const filtered = current.filter(item => !(item.kind === entry.kind && item.id === entry.id));
|
||||
const next = [ entry, ...filtered ].slice(0, MAX_ENTRIES);
|
||||
|
||||
return next;
|
||||
};
|
||||
|
||||
export const persistRecentLookups = (entries: RecentLookupEntry[]): void => writeStore(entries);
|
||||
|
||||
export const clearRecentLookups = (): void => writeStore([]);
|
||||
|
||||
export const RECENT_LOOKUPS_LIMIT = MAX_ENTRIES;
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
findTemplateById, HK_SANCTION_TEMPLATES, HousekeepingSanctionType, templatesByType
|
||||
} from './HousekeepingSanctionTemplates';
|
||||
|
||||
describe('HK_SANCTION_TEMPLATES', () =>
|
||||
{
|
||||
it('has a unique id for every template', () =>
|
||||
{
|
||||
const ids = HK_SANCTION_TEMPLATES.map(t => t.id);
|
||||
const unique = new Set(ids);
|
||||
|
||||
expect(unique.size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it('covers every sanction type at least once', () =>
|
||||
{
|
||||
const types = new Set(HK_SANCTION_TEMPLATES.map(t => t.type));
|
||||
|
||||
expect(types.has(HousekeepingSanctionType.BAN)).toBe(true);
|
||||
expect(types.has(HousekeepingSanctionType.MUTE)).toBe(true);
|
||||
expect(types.has(HousekeepingSanctionType.KICK)).toBe(true);
|
||||
expect(types.has(HousekeepingSanctionType.TRADE_LOCK)).toBe(true);
|
||||
});
|
||||
|
||||
it('uses durationValue=0 for KICK templates only (kick is instant, no duration)', () =>
|
||||
{
|
||||
for(const template of HK_SANCTION_TEMPLATES)
|
||||
{
|
||||
if(template.type === HousekeepingSanctionType.KICK) expect(template.durationValue).toBe(0);
|
||||
else expect(template.durationValue).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('every template has a non-empty default reason (avoids empty-reason validation failures)', () =>
|
||||
{
|
||||
for(const template of HK_SANCTION_TEMPLATES)
|
||||
{
|
||||
expect(template.defaultReason.trim().length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('findTemplateById', () =>
|
||||
{
|
||||
it('returns the matching template', () =>
|
||||
{
|
||||
expect(findTemplateById('ban_24h')?.type).toBe(HousekeepingSanctionType.BAN);
|
||||
expect(findTemplateById('ban_24h')?.durationValue).toBe(24);
|
||||
});
|
||||
|
||||
it('returns null for an unknown id', () =>
|
||||
{
|
||||
expect(findTemplateById('does-not-exist')).toBeNull();
|
||||
expect(findTemplateById('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('templatesByType', () =>
|
||||
{
|
||||
it('filters the list down to a single type', () =>
|
||||
{
|
||||
const bans = templatesByType(HousekeepingSanctionType.BAN);
|
||||
|
||||
expect(bans.length).toBeGreaterThan(0);
|
||||
expect(bans.every(t => t.type === HousekeepingSanctionType.BAN)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns an empty list for unknown types (defensive)', () =>
|
||||
{
|
||||
expect(templatesByType('unknown' as never)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
export const HousekeepingSanctionType = {
|
||||
BAN: 'ban',
|
||||
MUTE: 'mute',
|
||||
KICK: 'kick',
|
||||
TRADE_LOCK: 'trade_lock'
|
||||
} as const;
|
||||
|
||||
export type HousekeepingSanctionType = typeof HousekeepingSanctionType[keyof typeof HousekeepingSanctionType];
|
||||
|
||||
export interface HousekeepingSanctionTemplate
|
||||
{
|
||||
id: string;
|
||||
/** Display name (LocalizeText key OR plain label fallback). */
|
||||
name: string;
|
||||
type: HousekeepingSanctionType;
|
||||
/** Duration in hours for BAN / TRADE_LOCK, minutes for MUTE; ignored for KICK. */
|
||||
durationValue: number;
|
||||
/** Pre-canned reason — overridable from the UI textarea. */
|
||||
defaultReason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-canned sanction shortcuts. Lifted from the shape of mod-tools'
|
||||
* `MOD_ACTION_DEFINITIONS` (see `ModActionDefinition.ts`) but
|
||||
* simplified — HK doesn't need the CFH topic / sanctionTypeId
|
||||
* indirection because the HK HTTP API takes plain `(userId, reason,
|
||||
* duration)` triples.
|
||||
*
|
||||
* Operators that need different presets can mirror this file and
|
||||
* inject through the UI config layer down the road; for now keep
|
||||
* a flat default set covering the common cases.
|
||||
*/
|
||||
export const HK_SANCTION_TEMPLATES: HousekeepingSanctionTemplate[] = [
|
||||
{ id: 'kick', name: 'Kick', type: HousekeepingSanctionType.KICK, durationValue: 0, defaultReason: 'Removed from session' },
|
||||
{ id: 'mute_5m', name: 'Mute 5m', type: HousekeepingSanctionType.MUTE, durationValue: 5, defaultReason: 'Cool down — chat flood' },
|
||||
{ id: 'mute_60m', name: 'Mute 60m', type: HousekeepingSanctionType.MUTE, durationValue: 60, defaultReason: 'Mute — repeat offender' },
|
||||
{ id: 'ban_1h', name: 'Ban 1h', type: HousekeepingSanctionType.BAN, durationValue: 1, defaultReason: 'Temporary ban — rule violation' },
|
||||
{ id: 'ban_24h', name: 'Ban 24h', type: HousekeepingSanctionType.BAN, durationValue: 24, defaultReason: '24h ban — rule violation' },
|
||||
{ id: 'ban_7d', name: 'Ban 7d', type: HousekeepingSanctionType.BAN, durationValue: 168, defaultReason: '7-day ban — serious violation' },
|
||||
{ id: 'ban_30d', name: 'Ban 30d', type: HousekeepingSanctionType.BAN, durationValue: 720, defaultReason: '30-day ban — final warning' },
|
||||
{ id: 'ban_perm', name: 'Ban permanent', type: HousekeepingSanctionType.BAN, durationValue: 24 * 365 * 100, defaultReason: 'Permanent ban' },
|
||||
{ id: 'tlock_7d', name: 'Trade lock 7d', type: HousekeepingSanctionType.TRADE_LOCK, durationValue: 168, defaultReason: 'Trade lock — suspected scam' },
|
||||
{ id: 'tlock_perm', name: 'Trade lock perm', type: HousekeepingSanctionType.TRADE_LOCK, durationValue: 24 * 365 * 100, defaultReason: 'Permanent trade lock' }
|
||||
];
|
||||
|
||||
export const findTemplateById = (id: string): HousekeepingSanctionTemplate | null =>
|
||||
HK_SANCTION_TEMPLATES.find(t => t.id === id) ?? null;
|
||||
|
||||
export const templatesByType = (type: HousekeepingSanctionType): HousekeepingSanctionTemplate[] =>
|
||||
HK_SANCTION_TEMPLATES.filter(t => t.type === type);
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
HK_MAX_BAN_HOURS, HK_MAX_GIVE_AMOUNT, HK_MAX_RANK, HK_MIN_RANK, HousekeepingErrorKey,
|
||||
validateAmount, validateBanHours, validatePositiveId, validateRank, validateReason, validateUsername
|
||||
} from './HousekeepingValidation';
|
||||
|
||||
describe('validateUsername', () =>
|
||||
{
|
||||
it('rejects empty / whitespace-only input', () =>
|
||||
{
|
||||
expect(validateUsername('')).toBe(HousekeepingErrorKey.EMPTY_USERNAME);
|
||||
expect(validateUsername(' ')).toBe(HousekeepingErrorKey.EMPTY_USERNAME);
|
||||
expect(validateUsername(null)).toBe(HousekeepingErrorKey.EMPTY_USERNAME);
|
||||
});
|
||||
|
||||
it('accepts any non-empty trimmed value (server is source of truth for valid chars)', () =>
|
||||
{
|
||||
expect(validateUsername('alice')).toBe(HousekeepingErrorKey.NONE);
|
||||
expect(validateUsername(' Bob ')).toBe(HousekeepingErrorKey.NONE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePositiveId', () =>
|
||||
{
|
||||
it('rejects non-positive / non-integer / NaN / non-finite', () =>
|
||||
{
|
||||
expect(validatePositiveId(0, 'user')).toBe(HousekeepingErrorKey.INVALID_USER_ID);
|
||||
expect(validatePositiveId(-1, 'user')).toBe(HousekeepingErrorKey.INVALID_USER_ID);
|
||||
expect(validatePositiveId(1.5, 'user')).toBe(HousekeepingErrorKey.INVALID_USER_ID);
|
||||
expect(validatePositiveId(NaN, 'user')).toBe(HousekeepingErrorKey.INVALID_USER_ID);
|
||||
expect(validatePositiveId(Infinity, 'user')).toBe(HousekeepingErrorKey.INVALID_USER_ID);
|
||||
});
|
||||
|
||||
it('emits INVALID_ROOM_ID for the room kind', () =>
|
||||
{
|
||||
expect(validatePositiveId(0, 'room')).toBe(HousekeepingErrorKey.INVALID_ROOM_ID);
|
||||
expect(validatePositiveId(-2, 'room')).toBe(HousekeepingErrorKey.INVALID_ROOM_ID);
|
||||
});
|
||||
|
||||
it('accepts positive integers', () =>
|
||||
{
|
||||
expect(validatePositiveId(1, 'user')).toBe(HousekeepingErrorKey.NONE);
|
||||
expect(validatePositiveId(99999, 'room')).toBe(HousekeepingErrorKey.NONE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateAmount', () =>
|
||||
{
|
||||
it('rejects non-positive / non-integer / non-finite', () =>
|
||||
{
|
||||
expect(validateAmount(0)).toBe(HousekeepingErrorKey.INVALID_AMOUNT);
|
||||
expect(validateAmount(-5)).toBe(HousekeepingErrorKey.INVALID_AMOUNT);
|
||||
expect(validateAmount(1.5)).toBe(HousekeepingErrorKey.INVALID_AMOUNT);
|
||||
expect(validateAmount(NaN)).toBe(HousekeepingErrorKey.INVALID_AMOUNT);
|
||||
expect(validateAmount(Infinity)).toBe(HousekeepingErrorKey.INVALID_AMOUNT);
|
||||
});
|
||||
|
||||
it('rejects amounts above the cap', () =>
|
||||
{
|
||||
expect(validateAmount(HK_MAX_GIVE_AMOUNT + 1)).toBe(HousekeepingErrorKey.AMOUNT_TOO_LARGE);
|
||||
});
|
||||
|
||||
it('accepts the cap itself and any positive integer below it', () =>
|
||||
{
|
||||
expect(validateAmount(1)).toBe(HousekeepingErrorKey.NONE);
|
||||
expect(validateAmount(1000)).toBe(HousekeepingErrorKey.NONE);
|
||||
expect(validateAmount(HK_MAX_GIVE_AMOUNT)).toBe(HousekeepingErrorKey.NONE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateReason', () =>
|
||||
{
|
||||
it('rejects empty / whitespace-only', () =>
|
||||
{
|
||||
expect(validateReason('')).toBe(HousekeepingErrorKey.EMPTY_REASON);
|
||||
expect(validateReason(' ')).toBe(HousekeepingErrorKey.EMPTY_REASON);
|
||||
expect(validateReason(null)).toBe(HousekeepingErrorKey.EMPTY_REASON);
|
||||
});
|
||||
|
||||
it('accepts any non-empty reason', () =>
|
||||
{
|
||||
expect(validateReason('spam')).toBe(HousekeepingErrorKey.NONE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateBanHours', () =>
|
||||
{
|
||||
it('rejects non-positive / non-finite', () =>
|
||||
{
|
||||
expect(validateBanHours(0)).toBe(HousekeepingErrorKey.INVALID_HOURS);
|
||||
expect(validateBanHours(-1)).toBe(HousekeepingErrorKey.INVALID_HOURS);
|
||||
expect(validateBanHours(NaN)).toBe(HousekeepingErrorKey.INVALID_HOURS);
|
||||
expect(validateBanHours(Infinity)).toBe(HousekeepingErrorKey.INVALID_HOURS);
|
||||
});
|
||||
|
||||
it('rejects values above the 100-year cap', () =>
|
||||
{
|
||||
expect(validateBanHours(HK_MAX_BAN_HOURS + 1)).toBe(HousekeepingErrorKey.INVALID_HOURS);
|
||||
});
|
||||
|
||||
it('accepts the cap and any positive value below it (fractional included — minutes / partial hours)', () =>
|
||||
{
|
||||
expect(validateBanHours(1)).toBe(HousekeepingErrorKey.NONE);
|
||||
expect(validateBanHours(0.5)).toBe(HousekeepingErrorKey.NONE);
|
||||
expect(validateBanHours(HK_MAX_BAN_HOURS)).toBe(HousekeepingErrorKey.NONE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateRank', () =>
|
||||
{
|
||||
it('rejects out-of-range values (sub-min, above-max, non-integer, non-finite)', () =>
|
||||
{
|
||||
expect(validateRank(HK_MIN_RANK - 1)).toBe(HousekeepingErrorKey.INVALID_RANK);
|
||||
expect(validateRank(HK_MAX_RANK + 1)).toBe(HousekeepingErrorKey.INVALID_RANK);
|
||||
expect(validateRank(1.5)).toBe(HousekeepingErrorKey.INVALID_RANK);
|
||||
expect(validateRank(NaN)).toBe(HousekeepingErrorKey.INVALID_RANK);
|
||||
});
|
||||
|
||||
it('accepts boundary values', () =>
|
||||
{
|
||||
expect(validateRank(HK_MIN_RANK)).toBe(HousekeepingErrorKey.NONE);
|
||||
expect(validateRank(HK_MAX_RANK)).toBe(HousekeepingErrorKey.NONE);
|
||||
expect(validateRank(5)).toBe(HousekeepingErrorKey.NONE);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
export const HousekeepingErrorKey = {
|
||||
NONE: 'none',
|
||||
EMPTY_USERNAME: 'empty_username',
|
||||
INVALID_USER_ID: 'invalid_user_id',
|
||||
INVALID_ROOM_ID: 'invalid_room_id',
|
||||
INVALID_AMOUNT: 'invalid_amount',
|
||||
AMOUNT_TOO_LARGE: 'amount_too_large',
|
||||
EMPTY_REASON: 'empty_reason',
|
||||
INVALID_HOURS: 'invalid_hours',
|
||||
INVALID_RANK: 'invalid_rank'
|
||||
} as const;
|
||||
|
||||
export type HousekeepingErrorKey = typeof HousekeepingErrorKey[keyof typeof HousekeepingErrorKey];
|
||||
|
||||
export const HK_MAX_GIVE_AMOUNT = 1_000_000_000;
|
||||
export const HK_MAX_BAN_HOURS = 24 * 365 * 100;
|
||||
export const HK_MIN_RANK = 1;
|
||||
export const HK_MAX_RANK = 12;
|
||||
|
||||
export const validateUsername = (raw: string): HousekeepingErrorKey =>
|
||||
{
|
||||
if(!raw || raw.trim().length === 0) return HousekeepingErrorKey.EMPTY_USERNAME;
|
||||
|
||||
return HousekeepingErrorKey.NONE;
|
||||
};
|
||||
|
||||
export const validatePositiveId = (raw: number, kind: 'user' | 'room'): HousekeepingErrorKey =>
|
||||
{
|
||||
if(!Number.isFinite(raw) || !Number.isInteger(raw) || raw <= 0)
|
||||
{
|
||||
return kind === 'user' ? HousekeepingErrorKey.INVALID_USER_ID : HousekeepingErrorKey.INVALID_ROOM_ID;
|
||||
}
|
||||
|
||||
return HousekeepingErrorKey.NONE;
|
||||
};
|
||||
|
||||
export const validateAmount = (raw: number): HousekeepingErrorKey =>
|
||||
{
|
||||
if(!Number.isFinite(raw) || !Number.isInteger(raw) || raw <= 0) return HousekeepingErrorKey.INVALID_AMOUNT;
|
||||
if(raw > HK_MAX_GIVE_AMOUNT) return HousekeepingErrorKey.AMOUNT_TOO_LARGE;
|
||||
|
||||
return HousekeepingErrorKey.NONE;
|
||||
};
|
||||
|
||||
export const validateReason = (raw: string): HousekeepingErrorKey =>
|
||||
{
|
||||
if(!raw || raw.trim().length === 0) return HousekeepingErrorKey.EMPTY_REASON;
|
||||
|
||||
return HousekeepingErrorKey.NONE;
|
||||
};
|
||||
|
||||
export const validateBanHours = (raw: number): HousekeepingErrorKey =>
|
||||
{
|
||||
if(!Number.isFinite(raw) || raw <= 0) return HousekeepingErrorKey.INVALID_HOURS;
|
||||
if(raw > HK_MAX_BAN_HOURS) return HousekeepingErrorKey.INVALID_HOURS;
|
||||
|
||||
return HousekeepingErrorKey.NONE;
|
||||
};
|
||||
|
||||
export const validateRank = (raw: number): HousekeepingErrorKey =>
|
||||
{
|
||||
if(!Number.isFinite(raw) || !Number.isInteger(raw)) return HousekeepingErrorKey.INVALID_RANK;
|
||||
if(raw < HK_MIN_RANK || raw > HK_MAX_RANK) return HousekeepingErrorKey.INVALID_RANK;
|
||||
|
||||
return HousekeepingErrorKey.NONE;
|
||||
};
|
||||
@@ -0,0 +1,86 @@
|
||||
export interface IHousekeepingUser
|
||||
{
|
||||
id: number;
|
||||
username: string;
|
||||
motto: string;
|
||||
figure: string;
|
||||
rank: number;
|
||||
rankName: string;
|
||||
online: boolean;
|
||||
lastOnlineAt: number | null;
|
||||
creditsBalance: number;
|
||||
ducketsBalance: number;
|
||||
diamondsBalance: number;
|
||||
email: string;
|
||||
ipLast: string;
|
||||
isBanned: boolean;
|
||||
isMuted: boolean;
|
||||
isTradeLocked: boolean;
|
||||
}
|
||||
|
||||
export interface IHousekeepingRoom
|
||||
{
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
ownerId: number;
|
||||
ownerName: string;
|
||||
userCount: number;
|
||||
maxUsers: number;
|
||||
isLocked: boolean;
|
||||
isMuted: boolean;
|
||||
isPublic: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface IHousekeepingActionResult
|
||||
{
|
||||
ok: boolean;
|
||||
actionId: number | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface IHousekeepingActionLogEntry
|
||||
{
|
||||
id: number;
|
||||
timestamp: number;
|
||||
actorId: number;
|
||||
actorName: string;
|
||||
targetType: 'user' | 'room' | 'hotel';
|
||||
targetId: number | null;
|
||||
targetLabel: string;
|
||||
action: string;
|
||||
detail: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface IHousekeepingUserSummary
|
||||
{
|
||||
id: number;
|
||||
username: string;
|
||||
figure: string;
|
||||
online: boolean;
|
||||
rank: number;
|
||||
}
|
||||
|
||||
export interface IHousekeepingRoomSummary
|
||||
{
|
||||
id: number;
|
||||
name: string;
|
||||
userCount: number;
|
||||
ownerName: string;
|
||||
}
|
||||
|
||||
export interface IHousekeepingDashboard
|
||||
{
|
||||
onlineUsers: number;
|
||||
totalUsers: number;
|
||||
activeRooms: number;
|
||||
totalRooms: number;
|
||||
peakOnlineToday: number;
|
||||
peakOnlineAllTime: number;
|
||||
pendingTickets: number;
|
||||
sanctionsLast24h: number;
|
||||
serverUptimeSeconds: number;
|
||||
serverVersion: string;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -15,6 +15,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';
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { GetCommunication, IMessageEvent } from '@nitrots/nitro-renderer';
|
||||
|
||||
export interface AwaitMessageEventInit<T extends IMessageEvent, R = T>
|
||||
{
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
accept?: (event: T) => boolean;
|
||||
/**
|
||||
* Synchronous mapper that runs INSIDE the subscribe callback, while
|
||||
* the parser is still valid. Whatever it returns is what the Promise
|
||||
* resolves to. **MUST** be used for any read of `event.getParser()` —
|
||||
* the renderer recycles parser instances (the `_parser` field is
|
||||
* nulled / repopulated for the next packet) so reading the parser
|
||||
* AFTER the await microtask gives back null fields. Snapshot the
|
||||
* data here, return a plain object/value, then your async code is
|
||||
* safe.
|
||||
*/
|
||||
select?: (event: T) => R;
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 15_000;
|
||||
|
||||
/**
|
||||
* One-shot Promise adapter over the renderer's CommunicationManager.subscribeMessage.
|
||||
* Resolves on the first matching event, rejects on timeout / abort / connection error.
|
||||
* Used by request-response patterns (e.g. housekeeping lookups) that need a Promise
|
||||
* facade over the underlying packet stream.
|
||||
*
|
||||
* **Read the parser inside `select`, not after the await.** See the
|
||||
* AwaitMessageEventInit.select javadoc — the renderer recycles parsers,
|
||||
* so post-await reads come back null.
|
||||
*/
|
||||
export const awaitMessageEvent = <T extends IMessageEvent, R = T>(eventCtor: new (callback: (event: T) => void) => T, init: AwaitMessageEventInit<T, R> = {}): Promise<R> =>
|
||||
{
|
||||
const { timeoutMs = DEFAULT_TIMEOUT_MS, signal, accept, select } = init;
|
||||
|
||||
return new Promise<R>((resolve, reject) =>
|
||||
{
|
||||
if(signal?.aborted)
|
||||
{
|
||||
reject(new DOMException('aborted', 'AbortError'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const communication = GetCommunication();
|
||||
|
||||
if(!communication || !communication.connection)
|
||||
{
|
||||
reject(new Error('no_connection'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let settled = false;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let onAbort: (() => void) | null = null;
|
||||
|
||||
const cleanup = () =>
|
||||
{
|
||||
settled = true;
|
||||
if(unsubscribe) unsubscribe();
|
||||
unsubscribe = null;
|
||||
if(timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
if(onAbort && signal) signal.removeEventListener('abort', onAbort);
|
||||
onAbort = null;
|
||||
};
|
||||
|
||||
unsubscribe = communication.subscribeMessage(eventCtor, event =>
|
||||
{
|
||||
if(settled) return;
|
||||
|
||||
if(accept && !accept(event)) return;
|
||||
|
||||
// Snapshot the data synchronously: post-await reads of the
|
||||
// event's parser come back null because the renderer recycles
|
||||
// parser instances between packets. If no select supplied,
|
||||
// resolve with the raw event for backwards-compat callers
|
||||
// that don't touch the parser.
|
||||
let snapshot: R;
|
||||
|
||||
try
|
||||
{
|
||||
snapshot = select ? select(event) : (event as unknown as R);
|
||||
}
|
||||
catch(err)
|
||||
{
|
||||
cleanup();
|
||||
reject(err instanceof Error ? err : new Error(String(err)));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup();
|
||||
resolve(snapshot);
|
||||
});
|
||||
|
||||
timer = setTimeout(() =>
|
||||
{
|
||||
if(settled) return;
|
||||
cleanup();
|
||||
reject(new Error('timeout'));
|
||||
}, timeoutMs);
|
||||
|
||||
if(signal)
|
||||
{
|
||||
onAbort = () =>
|
||||
{
|
||||
if(settled) return;
|
||||
cleanup();
|
||||
reject(new DOMException('aborted', 'AbortError'));
|
||||
};
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './awaitMessageEvent';
|
||||
export * from './CreateLinkEvent';
|
||||
export * from './GetConfigurationValue';
|
||||
export * from './OpenUrl';
|
||||
|
||||
Reference in New Issue
Block a user