mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-20 07:26:19 +00:00
feat(housekeeping): in-client admin panel
Adds the Housekeeping in-client admin panel — a Modtools-adjacent
surface that runs entirely inside the React client, talking to the
emulator over the existing wire instead of a separate REST/CMS layer.
Surface:
- `src/components/housekeeping/` — panel shell + 5 tabs (Dashboard,
Users, Rooms, Economy, Audit). Each tab drives one domain of the
matching emulator handlers (find/sanction/admin/economy/catalog/
hotel-wide).
- `src/api/housekeeping/` — composer/parser orchestration:
`HousekeepingApi.ts` exposes 30+ typed actions, each one running
through `runHkAction()` which awaits the shared
`HousekeepingActionResultEvent` correlated by action key.
- `src/hooks/housekeeping/` — `useHousekeeping` (the public hook),
`useHousekeepingStore` (useBetween singleton: shared selection +
audit polling + sanction templates), `useHousekeepingActions`,
`useHousekeepingConfirm`.
- `src/api/nitro/awaitMessageEvent.ts` — Promise adapter over
`CommunicationManager.subscribeMessage` with a sync `select`
callback that snapshots the parser INSIDE the subscribe handler
before the renderer recycles the parser instance after the
Promise resolves.
- `public/configuration/housekeeping-texts-{en,it}.example` —
149 EN + 149 IT i18n keys under `housekeeping.*` for every panel
string + every server-side error slug the emulator may emit.
Wiring (additive only):
- `src/components/MainView.tsx` — `<HousekeepingView />` mounted
alongside `<ModToolsView />`.
- `src/api/index.ts`, `src/hooks/index.ts`, `src/api/nitro/index.ts`
— added the `housekeeping` and `awaitMessageEvent` re-exports.
Wire contract: pairs against the Arcturus PR (#120 on
duckietm/Arcturus-Morningstar-Extended) and the renderer PR (#77 on
duckietm/Nitro_Render_V3). Incoming events 9100..9129, outgoing
composers 9200..9207. Permission gate `acc_housekeeping` enforced
server-side; the panel is hidden client-side via
`housekeeping.enabled` in the runtime ui-config.
This commit is contained in:
@@ -0,0 +1,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;
|
||||
Reference in New Issue
Block a user