6edd2e3d52
Two real defects, both found by testing rather than reading: 1. The client sent sessionId in the POST body while the route read it from the query string, so the host always saw an empty id and answered total:null -- the pill was correct to stay silent. The client now sends the id in both places and the route reads both. 2. The ledger stored only a session total, so a restart kept the sum but dropped the per-purpose split and the call count (9.99 total against 6.24 in parts, seen on the live route). Sessions are now stored as objects; a bare number from an older ledger is still accepted. Also made the client report why it cannot read a total: swallowing the error made 'nothing spent yet' indistinguishable from 'request failed'.
516 lines
19 KiB
JavaScript
516 lines
19 KiB
JavaScript
window.__ModuleLoader__.load({
|
|
id: "dsh-session-cost",
|
|
factory: (require) => {
|
|
var module = { exports: {} }; var exports = module.exports;
|
|
// dsh-session-cost — client half (static bundle).
|
|
//
|
|
// One pill in the composer dock, right after the harness's own stats, showing
|
|
// what the current session has really cost. The number comes from the host
|
|
// half, which reads it out of the provider response; nothing is computed here.
|
|
//
|
|
// Layout contract:
|
|
// - the dock is a LIST slot, so registering under our own id adds a neighbour
|
|
// instead of taking the native `stats` seat the way dsh-bottom-info-bar does;
|
|
// - host data arrives over POST /_dsh/dsh-session-cost/<method> as JSON.
|
|
'use strict';
|
|
|
|
const React = require('react');
|
|
|
|
/** Route prefix the host half registers. */
|
|
const RPC_BASE = '/_dsh/dsh-session-cost';
|
|
|
|
/**
|
|
* The plugin context, captured in `apply`.
|
|
*
|
|
* Slot components receive only framework props, so the context is held here —
|
|
* the same shape the reference bottom-info-bar plugin uses.
|
|
*/
|
|
let runtimeCtx = null;
|
|
|
|
/** Poll gap while a session is active, and once idle. */
|
|
const POLL_ACTIVE_MS = 1000;
|
|
const POLL_IDLE_MS = 5000;
|
|
|
|
/** Give up on a request rather than leaving the pill loading forever. */
|
|
const RPC_TIMEOUT_MS = 15000;
|
|
|
|
/** Fall back to the browser language when the harness serves no locale. */
|
|
function detectLanguage() {
|
|
try {
|
|
const tag = typeof navigator !== 'undefined' && navigator.language ? navigator.language : 'en';
|
|
return String(tag).toLowerCase().startsWith('ru') ? 'ru' : 'en';
|
|
} catch (err) {
|
|
return 'en';
|
|
}
|
|
}
|
|
|
|
const LOCALES = {
|
|
ru: {
|
|
'pill.aria': 'Стоимость текущей сессии',
|
|
'pill.title': 'Стоимость сессии',
|
|
'pill.none': 'Пока ничего не потрачено',
|
|
'status.title': 'Стоимость сессии',
|
|
'status.description': 'Показывает реально списанную стоимость текущей сессии в нижнем баре.',
|
|
'status.session': 'Текущая сессия',
|
|
'status.total': 'Итого',
|
|
'status.calls': 'Оплаченных вызовов',
|
|
'status.byPurpose': 'По назначению',
|
|
'status.purpose.agent': 'Ответы агента',
|
|
'status.purpose.compaction': 'Компактификация',
|
|
'status.purpose.session-title': 'Заголовки сессий',
|
|
'status.health': 'Состояние учёта',
|
|
'status.observed': 'Прочитано ответов с ценой',
|
|
'status.attributed': 'Отнесено к сессиям',
|
|
'status.cancelled': 'Прервано (не тарифицируется)',
|
|
'status.failed': 'Завершилось ошибкой',
|
|
'status.unpriced': 'Без цены',
|
|
'status.unattributed': 'Не удалось отнести к сессии',
|
|
'status.waiting': 'Ожидают цену',
|
|
'status.parked': 'Цены без вызова',
|
|
'status.sessions': 'Сессий в реестре',
|
|
'status.readonly': 'Настроек у плагина нет: цена приходит от провайдера.',
|
|
'status.unavailable': 'Данные недоступны: хостовая половина не ответила.',
|
|
},
|
|
en: {
|
|
'pill.aria': 'Cost of the current session',
|
|
'pill.title': 'Session cost',
|
|
'pill.none': 'Nothing spent yet',
|
|
'status.title': 'Session cost',
|
|
'status.description': 'Shows what the current session has really cost, in the bottom bar.',
|
|
'status.session': 'Current session',
|
|
'status.total': 'Total',
|
|
'status.calls': 'Paid calls',
|
|
'status.byPurpose': 'By purpose',
|
|
'status.purpose.agent': 'Agent replies',
|
|
'status.purpose.compaction': 'Compaction',
|
|
'status.purpose.session-title': 'Session titles',
|
|
'status.health': 'Accounting health',
|
|
'status.observed': 'Responses read with a price',
|
|
'status.attributed': 'Attributed to sessions',
|
|
'status.cancelled': 'Cancelled (not billed)',
|
|
'status.failed': 'Finished with an error',
|
|
'status.unpriced': 'Without a price',
|
|
'status.unattributed': 'Could not place in a session',
|
|
'status.waiting': 'Waiting for a price',
|
|
'status.parked': 'Prices without a call',
|
|
'status.sessions': 'Sessions in the ledger',
|
|
'status.readonly': 'This plugin has no settings: the price comes from the provider.',
|
|
'status.unavailable': 'No data: the host half did not answer.',
|
|
},
|
|
};
|
|
|
|
/** Dictionary namespace owned by this plugin. */
|
|
const LOCALE_NAMESPACE = 'sessionCost';
|
|
|
|
/** Build the reader for one language, falling back to English. */
|
|
function makeTranslator() {
|
|
let language = detectLanguage();
|
|
const listeners = new Set();
|
|
const read = (key, fallback) => {
|
|
const table = LOCALES[language] ?? LOCALES.en;
|
|
return table[key] ?? LOCALES.en[key] ?? fallback ?? key;
|
|
};
|
|
return {
|
|
t: read,
|
|
get language() { return language; },
|
|
setLanguage(next) {
|
|
if (next !== 'ru' && next !== 'en') return;
|
|
if (next === language) return;
|
|
language = next;
|
|
listeners.forEach((fn) => fn());
|
|
},
|
|
subscribe(fn) {
|
|
listeners.add(fn);
|
|
return () => listeners.delete(fn);
|
|
},
|
|
};
|
|
}
|
|
|
|
const translator = makeTranslator();
|
|
const t = (key, fallback) => translator.t(key, fallback);
|
|
|
|
/** Milliseconds, and never longer than the timeout. */
|
|
function scheduleTimeout(fn, ms) {
|
|
try {
|
|
return setTimeout(fn, ms);
|
|
} catch (err) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function cancelTimeout(handle) {
|
|
if (handle === null || handle === undefined) return;
|
|
try {
|
|
clearTimeout(handle);
|
|
} catch (err) {
|
|
/* nothing to clear */
|
|
}
|
|
}
|
|
|
|
/** One JSON request to the host half. */
|
|
function rpc(method, args, query) {
|
|
let timedOut = false;
|
|
const controller = new AbortController();
|
|
const timer = scheduleTimeout(() => {
|
|
timedOut = true;
|
|
controller.abort();
|
|
}, RPC_TIMEOUT_MS);
|
|
const done = () => cancelTimeout(timer);
|
|
const suffix = query && Object.keys(query).length > 0
|
|
? '?' + new URLSearchParams(query).toString()
|
|
: '';
|
|
return fetch(RPC_BASE + '/' + method + suffix, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(args ?? {}),
|
|
signal: controller.signal,
|
|
})
|
|
.then((res) => {
|
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
return res.json();
|
|
})
|
|
.catch((err) => {
|
|
if (timedOut) throw new Error('timeout');
|
|
throw err;
|
|
})
|
|
.finally(done);
|
|
}
|
|
|
|
/** Two decimals would show 0,00 almost always; the owner chose four. */
|
|
function formatAmount(value) {
|
|
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
|
|
try {
|
|
return new Intl.NumberFormat(translator.language === 'ru' ? 'ru-RU' : 'en-US', {
|
|
minimumFractionDigits: 4,
|
|
maximumFractionDigits: 4,
|
|
}).format(value);
|
|
} catch (err) {
|
|
return value.toFixed(4).replace('.', translator.language === 'ru' ? ',' : '.');
|
|
}
|
|
}
|
|
|
|
/** Insert this plugin's stylesheet once. */
|
|
function installStyles() {
|
|
const tagId = 'dsh-session-cost/styles';
|
|
if (typeof document === 'undefined') return () => {};
|
|
if (document.querySelector('style[data-plugin-css="' + tagId + '"]') !== null) return () => {};
|
|
const style = document.createElement('style');
|
|
style.dataset.plugin = 'dsh-session-cost';
|
|
style.dataset.pluginCss = tagId;
|
|
style.textContent = [
|
|
'.dsh-session-cost{display:inline-flex;align-items:center;gap:6px;padding:4px 11px;',
|
|
'border-radius:999px;background:var(--dsw-alias-bg-l2, #1b2b21);',
|
|
'border:1px solid var(--dsw-alias-border-l1, #2c4a37);',
|
|
'color:var(--dsw-alias-label-secondary, #8fd6a8);font-size:12px;line-height:1.4;',
|
|
'font-variant-numeric:tabular-nums;white-space:nowrap;}',
|
|
'.dsh-session-cost__amount{font-weight:600;}',
|
|
'.dsh-sc-status{display:flex;flex-direction:column;gap:10px;padding:4px 0;font-size:13px;line-height:1.6;}',
|
|
'.dsh-sc-status h4{margin:6px 0 0;font-size:13px;font-weight:600;}',
|
|
'.dsh-sc-status table{border-collapse:collapse;width:100%;max-width:520px;}',
|
|
'.dsh-sc-status th{text-align:left;font-weight:400;color:var(--dsw-alias-label-tertiary, #8b929e);padding:2px 12px 2px 0;}',
|
|
'.dsh-sc-status td{text-align:right;font-variant-numeric:tabular-nums;padding:2px 0;}',
|
|
'.dsh-sc-status__note{color:var(--dsw-alias-label-tertiary, #8b929e);}',
|
|
'.dsh-sc-status__error{color:var(--dsw-alias-label-error, #e06c75);}',
|
|
].join('');
|
|
document.head.appendChild(style);
|
|
return () => {
|
|
if (style.parentNode) style.parentNode.removeChild(style);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Ask the host who this session is.
|
|
*
|
|
* The standard slot kit answers first; the session snapshot and the runtime
|
|
* sessions service are fallbacks, because host versions differ.
|
|
*/
|
|
function useSessionId(props) {
|
|
return React.useMemo(() => {
|
|
// Why the fallback chain exists: the host route answers `total: null` for an
|
|
// id it does not know, and the pill then stays silent -- so a wrong id here
|
|
// looks exactly like "nothing spent yet" unless it is reported.
|
|
const seen = [];
|
|
const record = (source, value) => seen.push(source + '=' + JSON.stringify(value ?? null));
|
|
let resolved = '';
|
|
try {
|
|
record('props.sessionId', props.sessionId);
|
|
record('props.session.sessionId', props.session && props.session.sessionId);
|
|
record('props keys', Object.keys(props).slice(0, 12));
|
|
|
|
if (typeof props.sessionId === 'string' && props.sessionId.length > 0) resolved = props.sessionId;
|
|
else if (props.session && typeof props.session.sessionId === 'string') resolved = props.session.sessionId;
|
|
else {
|
|
const sessions = runtimeCtx && runtimeCtx.get ? runtimeCtx.get('sessions') : null;
|
|
const current = sessions && sessions.list ? sessions.list.getSnapshot().current : null;
|
|
record('sessions.list.current', current);
|
|
if (typeof current === 'string' && current.length > 0) resolved = current;
|
|
}
|
|
} catch (err) {
|
|
record('error', String((err && err.message) || err));
|
|
}
|
|
seen.push('resolved=' + JSON.stringify(resolved));
|
|
// Report once per distinct outcome so the console stays readable.
|
|
const summary = seen.join(' | ');
|
|
if (globalThis.__dshSessionCostId !== summary) {
|
|
globalThis.__dshSessionCostId = summary;
|
|
console.info('[dsh-session-cost] session id: ' + summary);
|
|
}
|
|
return resolved;
|
|
}, [props.sessionId, props.session]);
|
|
}
|
|
|
|
/** Poll the host for one session's running total. */
|
|
function useSessionCost(sessionId) {
|
|
const [state, setState] = React.useState({ total: null, loading: true, error: null });
|
|
React.useEffect(() => {
|
|
let cancelled = false;
|
|
let timer = null;
|
|
|
|
const load = () => {
|
|
// The id goes in the query string as well as the body: the route reads
|
|
// either, so this keeps working against a host that predates the fix.
|
|
rpc('session', { sessionId }, { sessionId })
|
|
.then((data) => {
|
|
if (cancelled) return;
|
|
setState({ total: data && typeof data.total === 'number' ? data.total : null, loading: false, error: null });
|
|
})
|
|
.catch((err) => {
|
|
if (cancelled) return;
|
|
// Say why, once per distinct failure, instead of silently keeping the
|
|
// pill empty: "no spend yet" and "cannot reach the host" are
|
|
// indistinguishable otherwise, and only one of them is normal.
|
|
const why = String((err && err.message) || err);
|
|
if (globalThis.__dshSessionCostError !== why) {
|
|
globalThis.__dshSessionCostError = why;
|
|
console.warn('[dsh-session-cost] cannot read ' + RPC_BASE + '/session: ' + why);
|
|
}
|
|
setState((prev) => ({ total: prev.total, loading: false, error: why }));
|
|
})
|
|
.finally(() => {
|
|
if (cancelled) return;
|
|
const gap = typeof document !== 'undefined' && document.visibilityState === 'visible'
|
|
? POLL_ACTIVE_MS
|
|
: POLL_IDLE_MS;
|
|
timer = scheduleTimeout(load, gap);
|
|
});
|
|
};
|
|
|
|
load();
|
|
return () => {
|
|
cancelled = true;
|
|
cancelTimeout(timer);
|
|
};
|
|
}, [sessionId]);
|
|
return state;
|
|
}
|
|
|
|
/** The pill itself: one figure, right after the harness's own stats. */
|
|
function SessionCostPill(props) {
|
|
const sessionId = useSessionId(props);
|
|
const { total } = useSessionCost(sessionId);
|
|
const [, bump] = React.useState(0);
|
|
|
|
React.useEffect(() => translator.subscribe(() => bump((v) => v + 1)), []);
|
|
|
|
if (total === null) return null; // no paid call yet: say nothing
|
|
const amount = formatAmount(total);
|
|
if (amount === null) return null;
|
|
|
|
return React.createElement(
|
|
'span',
|
|
{ className: 'dsh-session-cost', title: t('pill.title'), 'aria-label': t('pill.aria') },
|
|
React.createElement('span', { className: 'dsh-session-cost__amount' }, '\u20BD ' + amount),
|
|
);
|
|
}
|
|
|
|
/** One label/value row in the status page. */
|
|
function Row(props) {
|
|
return React.createElement(
|
|
'tr',
|
|
null,
|
|
React.createElement('th', null, props.label),
|
|
React.createElement('td', null, props.value),
|
|
);
|
|
}
|
|
|
|
/** The plugin page: read-only diagnostics, because there is nothing to set. */
|
|
function SessionCostStatus(props) {
|
|
const [data, setData] = React.useState(null);
|
|
const [failed, setFailed] = React.useState(false);
|
|
const [, bump] = React.useState(0);
|
|
|
|
React.useEffect(() => translator.subscribe(() => bump((v) => v + 1)), []);
|
|
|
|
React.useEffect(() => {
|
|
let cancelled = false;
|
|
let timer = null;
|
|
const load = () => {
|
|
rpc('status', {})
|
|
.then((payload) => {
|
|
if (cancelled) return;
|
|
setData(payload);
|
|
setFailed(false);
|
|
})
|
|
.catch(() => {
|
|
if (cancelled) return;
|
|
setFailed(true);
|
|
})
|
|
.finally(() => {
|
|
if (!cancelled) timer = scheduleTimeout(load, 3000);
|
|
});
|
|
};
|
|
load();
|
|
return () => {
|
|
cancelled = true;
|
|
cancelTimeout(timer);
|
|
};
|
|
}, []);
|
|
|
|
if (props.view === 'summary') return t('status.description');
|
|
|
|
const stats = data && data.stats ? data.stats : null;
|
|
const purposes = data && data.byPurpose ? Object.entries(data.byPurpose) : [];
|
|
|
|
return React.createElement(
|
|
'div',
|
|
{ className: 'dsh-sc-status' },
|
|
React.createElement('div', { className: 'dsh-sc-status__note' }, t('status.readonly')),
|
|
failed ? React.createElement('div', { className: 'dsh-sc-status__error' }, t('status.unavailable')) : null,
|
|
React.createElement('h4', null, t('status.session')),
|
|
React.createElement(
|
|
'table',
|
|
null,
|
|
React.createElement(
|
|
'tbody',
|
|
null,
|
|
React.createElement(Row, { label: t('status.session'), value: data?.sessionId || '\u2014' }),
|
|
React.createElement(Row, { label: t('status.total'), value: '\u20BD ' + (formatAmount(data?.total ?? 0) ?? '0,0000') }),
|
|
React.createElement(Row, { label: t('status.calls'), value: String(data?.calls ?? 0) }),
|
|
),
|
|
),
|
|
purposes.length > 0
|
|
? React.createElement(
|
|
React.Fragment,
|
|
null,
|
|
React.createElement('h4', null, t('status.byPurpose')),
|
|
React.createElement(
|
|
'table',
|
|
null,
|
|
React.createElement(
|
|
'tbody',
|
|
null,
|
|
purposes.map(([name, amount]) =>
|
|
React.createElement(Row, {
|
|
key: name,
|
|
label: t('status.purpose.' + name, name),
|
|
value: '\u20BD ' + (formatAmount(amount) ?? '0,0000'),
|
|
}),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
: null,
|
|
stats !== null
|
|
? React.createElement(
|
|
React.Fragment,
|
|
null,
|
|
React.createElement('h4', null, t('status.health')),
|
|
React.createElement(
|
|
'table',
|
|
null,
|
|
React.createElement(
|
|
'tbody',
|
|
null,
|
|
React.createElement(Row, { label: t('status.observed'), value: String(stats.observed) }),
|
|
React.createElement(Row, { label: t('status.attributed'), value: String(stats.attributed) }),
|
|
React.createElement(Row, { label: t('status.cancelled'), value: String(stats.cancelled) }),
|
|
React.createElement(Row, { label: t('status.failed'), value: String(stats.failed) }),
|
|
React.createElement(Row, { label: t('status.unpriced'), value: String(stats.unpriced) }),
|
|
React.createElement(Row, { label: t('status.unattributed'), value: String(stats.unattributed) }),
|
|
React.createElement(Row, { label: t('status.waiting'), value: String(stats.waiting) }),
|
|
React.createElement(Row, { label: t('status.parked'), value: String(stats.parked) }),
|
|
React.createElement(Row, { label: t('status.sessions'), value: String(stats.sessions) }),
|
|
),
|
|
),
|
|
)
|
|
: null,
|
|
);
|
|
}
|
|
|
|
exports.inject = ['slots', 'locale'];
|
|
exports.apply = async function apply(ctx) {
|
|
// The locale service may be absent or late; either way the plugin keeps working.
|
|
let locale = null;
|
|
try {
|
|
locale = ctx.locale;
|
|
} catch (err) {
|
|
locale = null;
|
|
}
|
|
if (!locale && typeof ctx.get === 'function') {
|
|
try {
|
|
locale = ctx.get('locale');
|
|
} catch (err) {
|
|
locale = null;
|
|
}
|
|
}
|
|
if (locale && typeof locale.register === 'function') {
|
|
try {
|
|
const dispose = locale.register(LOCALE_NAMESPACE, LOCALES);
|
|
ctx.effect(() => dispose, 'dsh-session-cost: dictionaries');
|
|
} catch (err) {
|
|
/* fall back to the browser language */
|
|
}
|
|
}
|
|
|
|
ctx.effect(() => installStyles(), 'dsh-session-cost: styles');
|
|
|
|
runtimeCtx = ctx;
|
|
ctx.effect(() => () => { runtimeCtx = null; }, 'dsh-session-cost: context');
|
|
|
|
let slots = ctx.slots || (ctx.get ? ctx.get('slots') : undefined);
|
|
for (let i = 0; slots === undefined && i < 60; i++) {
|
|
await new Promise((resolve) => {
|
|
if (scheduleTimeout(resolve, Math.min(300 + i * 20, 1000)) === null) resolve();
|
|
});
|
|
slots = ctx.slots || (ctx.get ? ctx.get('slots') : undefined);
|
|
}
|
|
if (slots === undefined) {
|
|
console.warn('[dsh-session-cost] the slots service never became available');
|
|
return;
|
|
}
|
|
|
|
// The dock is a list slot: our own id adds a neighbour and leaves the
|
|
// harness's `stats` pill exactly where it is.
|
|
ctx.effect(
|
|
() =>
|
|
slots.inject('conversation.composer.dock', () =>
|
|
slots.register(
|
|
{ name: 'conversation.composer.dock', id: 'session-cost', order: 10, locale: LOCALE_NAMESPACE },
|
|
SessionCostPill,
|
|
),
|
|
),
|
|
'dsh-session-cost: pill',
|
|
);
|
|
|
|
// The plugin's own page on the Plugins screen.
|
|
ctx.effect(
|
|
() =>
|
|
slots.inject('plugins.bundle.config', () =>
|
|
slots.register(
|
|
{
|
|
name: 'plugins.bundle.config',
|
|
key: 'dsh-session-cost',
|
|
locale: LOCALE_NAMESPACE,
|
|
label: () => t('status.title'),
|
|
},
|
|
SessionCostStatus,
|
|
),
|
|
),
|
|
'dsh-session-cost: page',
|
|
);
|
|
};
|
|
|
|
return module.exports;
|
|
},
|
|
});
|