Merge pull request #157 from simoleo89/feat/housekeeping-panel

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