neuroalpha 0.7.1 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/public/app.js +196 -168
- package/public/index.html +6 -2
- package/public/styles.css +56 -0
- package/server/auth/index.js +8 -3
- package/server/llm/providers.js +0 -9
- package/server/tls-ca.js +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "neuroalpha",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
4
4
|
"description": "НейроАльфа — автономный торговый терминал для рынка акций Мосбиржи: эмбеддинги акций, самообучение правил, коннекторы Т-Банк / БКС / Финам / Алор, активация токеном ЛК и автоустановка локальной ИИ-модели Стратега",
|
|
5
5
|
"author": "Roman A. Perepelkin",
|
|
6
6
|
"license": "UNLICENSED",
|
package/public/app.js
CHANGED
|
@@ -270,6 +270,8 @@ function showMainSec(sec) {
|
|
|
270
270
|
loadNews();
|
|
271
271
|
} else if (sec === 'cloud') {
|
|
272
272
|
renderCloudPane().catch(() => {});
|
|
273
|
+
} else if (sec === 'access') {
|
|
274
|
+
renderAccessPane().catch(() => {});
|
|
273
275
|
}
|
|
274
276
|
}
|
|
275
277
|
const switchTab = (t) => showMainSec(t);
|
|
@@ -4047,227 +4049,237 @@ async function renderCloudPane() {
|
|
|
4047
4049
|
state.config = { ...(state.config || {}), ...me };
|
|
4048
4050
|
const u = me.user;
|
|
4049
4051
|
const lic = me.license || u?.license || {};
|
|
4050
|
-
const nt = me.notify || {};
|
|
4051
4052
|
updateTabBadges();
|
|
4052
4053
|
|
|
4053
4054
|
const portalUrl = lic.portalUrl || 'https://neuroalpha.odant.org';
|
|
4055
|
+
const portalHost = portalUrl.replace(/^https?:\/\//, '');
|
|
4054
4056
|
const slots = Array.isArray(lic.slots) ? lic.slots : [];
|
|
4057
|
+
const mainSlot = slots[0] || null;
|
|
4058
|
+
const allGrants = (mainSlot?.grants || []).filter((g) => g.status !== 'expired');
|
|
4055
4059
|
|
|
4056
|
-
const
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4060
|
+
const applyActivatedState = async () => {
|
|
4061
|
+
state.config = await api('/api/config');
|
|
4062
|
+
updateTabBadges();
|
|
4063
|
+
await renderCloudPane();
|
|
4064
|
+
await loadBrokers().catch(() => {});
|
|
4065
|
+
await loadLlm().catch(() => {});
|
|
4066
|
+
if (isOwnerUser()) await loadRuns().catch(() => {});
|
|
4067
|
+
if (isLicensed()) refreshForecastIfStale();
|
|
4068
|
+
else loadForecast(false, true);
|
|
4069
|
+
};
|
|
4065
4070
|
|
|
4071
|
+
// Квота счетов (Плитка 1)
|
|
4072
|
+
const maxAcc = u?.limits?.maxBrokerAccounts ?? 0;
|
|
4066
4073
|
const capLimStr = u?.limits?.maxCapitalPerAccountRub >= 100000000
|
|
4067
|
-
? 'без ограничений'
|
|
4074
|
+
? 'без ограничений по капиталу'
|
|
4068
4075
|
: u?.limits?.maxCapitalPerAccountRub > 0
|
|
4069
|
-
? `до ${fmtNum(u.limits.maxCapitalPerAccountRub, 0)} ₽ на
|
|
4070
|
-
: '
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
|
|
4075
|
-
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
|
|
4088
|
-
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
|
|
4093
|
-
|
|
4094
|
-
|
|
4095
|
-
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
|
|
4076
|
+
? `до ${fmtNum(u.limits.maxCapitalPerAccountRub, 0)} ₽ на счёт`
|
|
4077
|
+
: 'требуется лицензия';
|
|
4078
|
+
|
|
4079
|
+
// Торговый контур (Плитка 2)
|
|
4080
|
+
const tradeOn = Boolean(lic.valid);
|
|
4081
|
+
const tradeSub = tradeOn
|
|
4082
|
+
? (u?.limits?.allowShorts ? 'Покупки и шорты (Long / Short)' : 'Только покупки (Long)')
|
|
4083
|
+
: 'Привяжите токен из кабинета';
|
|
4084
|
+
|
|
4085
|
+
// Строка токена (внизу Карточки 1 — точно как в эталоне WORK)
|
|
4086
|
+
const tokenRowHtml = lic.hasCabinetToken
|
|
4087
|
+
? `<div class="lic-token-row">
|
|
4088
|
+
<input type="text" readonly value="${esc(lic.maskedToken || 'na_tok_…')}" title="Привязанный токен личного кабинета">
|
|
4089
|
+
<button type="button" class="btn-unlink-soft" id="btn-lic-detach">Отвязать токен</button>
|
|
4090
|
+
</div>`
|
|
4091
|
+
: `<form id="form-lic-bind" class="lic-token-row">
|
|
4092
|
+
<input id="lic-token-in" name="token" type="text" placeholder="Вставьте токен привязки na_tok_… из личного кабинета" required spellcheck="false">
|
|
4093
|
+
<button type="submit" class="primary" style="height:38px;padding:0 18px">Загрузить лицензии</button>
|
|
4094
|
+
</form>`;
|
|
4095
|
+
|
|
4096
|
+
// Таблица установленных пакетов лицензий (Карточка 2)
|
|
4097
|
+
const rowsHtml = allGrants.length
|
|
4098
|
+
? allGrants.map((g) => {
|
|
4099
|
+
const expText = g.durationDays === 0
|
|
4100
|
+
? 'Бессрочно (постоянная)'
|
|
4101
|
+
: (g.endsAt ? `до ${fmtDate(g.endsAt)} (${g.remainingDays ?? g.durationDays} сут.)` : `${g.remainingDays ?? g.durationDays} сут. (в очереди)`);
|
|
4102
|
+
const stText = g.status === 'active' ? '<span class="pos">Активна</span>' : '<span class="muted">В очереди</span>';
|
|
4103
|
+
return `<tr>
|
|
4104
|
+
<td><b>Лицензия «${esc(g.planName)}»</b></td>
|
|
4105
|
+
<td class="muted small">${esc(mainSlot?.hostname || mainSlot?.id || 'этот ПК')}</td>
|
|
4106
|
+
<td><b>${esc(g.plan === 'owner' ? 'Без ограничений' : g.plan === 'vip' ? 'до 50 счетов' : g.plan === 'pro' ? 'до 5 счетов' : '1 счёт')}</b></td>
|
|
4107
|
+
<td>${expText}</td>
|
|
4108
|
+
<td>${stText}</td>
|
|
4109
|
+
</tr>`;
|
|
4110
|
+
}).join('')
|
|
4111
|
+
: (lic.valid
|
|
4112
|
+
? `<tr>
|
|
4113
|
+
<td><b>Лицензия «${esc(u?.planName || lic.planName)}»</b></td>
|
|
4114
|
+
<td class="muted small">${esc(lic.licId || 'этот ПК')}</td>
|
|
4115
|
+
<td><b>до ${maxAcc} счетов</b></td>
|
|
4116
|
+
<td>${lic.perpetual ? 'Бессрочно (постоянная)' : `до ${fmtDate(lic.validUntil)}`}</td>
|
|
4117
|
+
<td><span class="pos">Активна</span></td>
|
|
4118
|
+
</tr>`
|
|
4119
|
+
: `<tr><td colspan="5" class="muted small">Установленных лицензий нет — перейдите в личный кабинет по кнопке выше, скопируйте токен <code>na_tok_…</code> и загрузите лицензию.</td></tr>`);
|
|
4120
|
+
|
|
4121
|
+
const packagesCount = allGrants.length || (lic.valid ? 1 : 0);
|
|
4099
4122
|
|
|
4100
4123
|
box.innerHTML = `
|
|
4101
|
-
<div class="
|
|
4102
|
-
<div
|
|
4103
|
-
<div
|
|
4104
|
-
|
|
4105
|
-
Срок: ${validStr}${lic.email ? ` · Аккаунт: <b>${esc(lic.email)}</b>` : ''}<br>
|
|
4106
|
-
${lic.valid ? `Квота счетов: до <b>${u?.limits?.maxBrokerAccounts ?? 0}</b> (${capLimStr}) · Режим: <b>${dirLimStr}</b>.` : 'Без действующей лицензии любые торговые операции и живой радар сигналов заблокированы.'}
|
|
4124
|
+
<div class="lic-card">
|
|
4125
|
+
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
|
|
4126
|
+
<div style="font-size:16px;font-weight:700">
|
|
4127
|
+
◈ Лицензирование НейроАльфа <span style="font-weight:600;color:${lic.valid ? 'var(--green)' : 'var(--red)'}">${lic.valid ? 'Лицензия активна · Торговля включена' : (lic.expired ? 'Срок лицензии истёк · Торговля отключена' : 'Лицензия не установлена · Торговля отключена')}</span>
|
|
4107
4128
|
</div>
|
|
4129
|
+
<button type="button" id="btn-edit-portal-url" class="small" style="padding:3px 9px" title="Изменить адрес сервера лицензий">Адрес портала</button>
|
|
4108
4130
|
</div>
|
|
4109
|
-
<div class="row-gap" style="flex-wrap:wrap">
|
|
4110
|
-
${lic.hasCabinetToken ? '<button type="button" class="primary" id="btn-lic-refresh">↻ Обновить лицензии с сайта</button>' : ''}
|
|
4111
|
-
${lic.hasCabinetToken ? '<button type="button" id="btn-lic-detach" style="color:var(--red)">Отключить токен</button>' : ''}
|
|
4112
|
-
${!me.isLoopback ? '<button type="button" id="btn-term-logout" style="color:var(--red)">Выйти из сессии</button>' : ''}
|
|
4113
|
-
</div>
|
|
4114
|
-
</div>
|
|
4115
4131
|
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
<div class="row-gap" style="flex-wrap:wrap">
|
|
4122
|
-
<input id="lic-portal-url" value="${esc(portalUrl)}" placeholder="https://neuroalpha.odant.org" style="width:250px" title="Адрес портала лицензий">
|
|
4123
|
-
<a id="btn-open-portal" class="btn primary" href="${esc(portalUrl)}" target="_blank" rel="noopener">🌐 Открыть сайт / Личный кабинет →</a>
|
|
4132
|
+
<div style="margin-top:10px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
|
4133
|
+
<a id="btn-open-portal" class="btn" href="${esc(portalUrl)}/#cabinet" target="_blank" rel="noopener" style="border-color:var(--accent);color:var(--accent);font-weight:600">
|
|
4134
|
+
Личный кабинет (${esc(portalHost)}) ↗
|
|
4135
|
+
</a>
|
|
4136
|
+
<input id="lic-portal-url" value="${esc(portalUrl)}" placeholder="https://neuroalpha.odant.org" hidden style="width:260px;height:32px">
|
|
4124
4137
|
</div>
|
|
4125
|
-
</div>
|
|
4126
4138
|
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4139
|
+
<div class="lic-stats-grid">
|
|
4140
|
+
<div class="lic-stat-tile">
|
|
4141
|
+
<div class="lic-stat-lbl">Тариф и квота счетов</div>
|
|
4142
|
+
<div class="lic-stat-val" style="color:${lic.valid ? 'var(--accent)' : 'var(--muted)'}">${lic.valid ? `${esc(u?.planName || lic.planName)} (${maxAcc >= 100 ? '∞' : `до ${maxAcc}`})` : 'Не активен'}</div>
|
|
4143
|
+
<div class="lic-stat-sub">${esc(capLimStr)}</div>
|
|
4144
|
+
</div>
|
|
4145
|
+
<div class="lic-stat-tile">
|
|
4146
|
+
<div class="lic-stat-lbl">Торговый контур и сигналы</div>
|
|
4147
|
+
<div class="lic-stat-val">${tradeOn ? 'Включены' : 'Отключены'}</div>
|
|
4148
|
+
<div class="lic-stat-sub">${esc(tradeSub)}</div>
|
|
4149
|
+
</div>
|
|
4150
|
+
<div class="lic-stat-tile">
|
|
4151
|
+
<div class="lic-stat-lbl">Аккаунт владельца и портал</div>
|
|
4152
|
+
<div class="lic-stat-val" style="font-size:14.5px;word-break:break-all">${esc(lic.email || 'Токен не привязан')}</div>
|
|
4153
|
+
<div class="lic-stat-sub"><a href="${esc(portalUrl)}/#cabinet" target="_blank" rel="noopener">${esc(portalUrl)} ↗</a></div>
|
|
4154
|
+
</div>
|
|
4132
4155
|
</div>
|
|
4133
|
-
<form id="form-lic-bind" class="row-gap" style="flex-wrap:wrap">
|
|
4134
|
-
<input id="lic-token-in" name="token" placeholder="na_tok_…" style="width:300px;font-family:monospace" required spellcheck="false">
|
|
4135
|
-
<button type="submit" class="primary" id="btn-lic-download">⬇ Загрузить лицензии</button>
|
|
4136
|
-
</form>
|
|
4137
|
-
</div>
|
|
4138
4156
|
|
|
4139
|
-
|
|
4140
|
-
<div class="slbl">Загруженные лицензии и очередь пакетов</div>
|
|
4141
|
-
${slotsTableHtml}
|
|
4157
|
+
${tokenRowHtml}
|
|
4142
4158
|
</div>
|
|
4143
4159
|
|
|
4144
|
-
<div class="
|
|
4145
|
-
<div
|
|
4146
|
-
<div
|
|
4147
|
-
<div class="
|
|
4148
|
-
|
|
4160
|
+
<div class="lic-card">
|
|
4161
|
+
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:14px">
|
|
4162
|
+
<div style="font-size:15.5px;font-weight:700">📦 Установленные пакеты лицензий</div>
|
|
4163
|
+
<div class="row-gap">
|
|
4164
|
+
<button type="button" id="btn-lic-refresh" ${!lic.hasCabinetToken ? 'disabled' : ''}>Проверить обновления лицензий</button>
|
|
4165
|
+
<span class="muted small">${packagesCount} пакет(ов)</span>
|
|
4149
4166
|
</div>
|
|
4150
4167
|
</div>
|
|
4151
|
-
<form id="form-local-pass" class="row-gap" style="flex-wrap:wrap">
|
|
4152
|
-
<input name="password" type="password" autocomplete="new-password" placeholder="${lic.hasLocalPassword ? 'Новый пароль (пусто — сбросить)' : 'Задать пароль (от 6 симв.)'}" style="width:240px">
|
|
4153
|
-
<button type="submit">Сохранить пароль VPS</button>
|
|
4154
|
-
</form>
|
|
4155
|
-
</div>
|
|
4156
4168
|
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
|
|
4162
|
-
|
|
4163
|
-
|
|
4164
|
-
|
|
4165
|
-
|
|
4169
|
+
<div style="overflow-x:auto">
|
|
4170
|
+
<table style="width:100%;font-size:13px">
|
|
4171
|
+
<thead>
|
|
4172
|
+
<tr>
|
|
4173
|
+
<th>ТАРИФ ЛИЦЕНЗИИ</th>
|
|
4174
|
+
<th>УСТРОЙСТВО</th>
|
|
4175
|
+
<th>КВОТА СЧЕТОВ</th>
|
|
4176
|
+
<th>СРОК ДЕЙСТВИЯ</th>
|
|
4177
|
+
<th>СТАТУС</th>
|
|
4178
|
+
</tr>
|
|
4179
|
+
</thead>
|
|
4180
|
+
<tbody>${rowsHtml}</tbody>
|
|
4181
|
+
</table>
|
|
4166
4182
|
</div>
|
|
4167
4183
|
</div>
|
|
4168
|
-
|
|
4169
|
-
<details style="margin-top:8px;padding-top:10px;border-top:1px solid var(--line)">
|
|
4170
|
-
<summary class="muted small" style="cursor:pointer;font-weight:500">⚙ Прямое подключение собственного личного бота Telegram / MAX (автономно без портала)</summary>
|
|
4171
|
-
<form id="direct-notify-form" style="display:grid;gap:10px;margin-top:10px">
|
|
4172
|
-
<label class="row-gap small"><input type="checkbox" name="usePortalRelay" ${nt.usePortalRelay !== false ? 'checked' : ''}> Разрешить отправку через ботов портала, если прямой бот не заполнен</label>
|
|
4173
|
-
<div class="row-gap" style="flex-wrap:wrap">
|
|
4174
|
-
<input name="tgToken" type="text" class="secret-input" autocomplete="off" placeholder="${nt.hasDirectTg ? 'Токен личного Telegram-бота задан' : 'Токен Telegram Bot API (@BotFather)'}" style="flex:1;min-width:240px">
|
|
4175
|
-
<input name="tgChatId" value="${esc(nt.tgChatId || '')}" placeholder="Telegram Chat ID (заполнится при /start)" style="width:240px">
|
|
4176
|
-
</div>
|
|
4177
|
-
<div class="row-gap" style="flex-wrap:wrap">
|
|
4178
|
-
<input name="maxToken" type="text" class="secret-input" autocomplete="off" placeholder="${nt.hasDirectMax ? 'Токен личного MAX-бота задан' : 'Токен MAX Bot API'}" style="flex:1;min-width:240px">
|
|
4179
|
-
<input name="maxChatId" value="${esc(nt.maxChatId || '')}" placeholder="MAX Chat ID" style="width:240px">
|
|
4180
|
-
<button type="submit" class="primary">Сохранить прямого бота</button>
|
|
4181
|
-
</div>
|
|
4182
|
-
</form>
|
|
4183
|
-
</details>
|
|
4184
4184
|
`;
|
|
4185
4185
|
|
|
4186
4186
|
const portalInput = box.querySelector('#lic-portal-url');
|
|
4187
4187
|
const openPortalLink = box.querySelector('#btn-open-portal');
|
|
4188
|
+
box.querySelector('#btn-edit-portal-url')?.addEventListener('click', () => {
|
|
4189
|
+
if (portalInput) {
|
|
4190
|
+
portalInput.hidden = !portalInput.hidden;
|
|
4191
|
+
if (!portalInput.hidden) portalInput.focus();
|
|
4192
|
+
}
|
|
4193
|
+
});
|
|
4188
4194
|
portalInput?.addEventListener('input', () => {
|
|
4189
|
-
const v = portalInput.value.trim() || 'https://neuroalpha.odant.org';
|
|
4190
|
-
if (openPortalLink && /^https?:\/\//i.test(v))
|
|
4195
|
+
const v = (portalInput.value.trim() || 'https://neuroalpha.odant.org').replace(/\/+$/, '');
|
|
4196
|
+
if (openPortalLink && /^https?:\/\//i.test(v)) {
|
|
4197
|
+
openPortalLink.href = `${v}/#cabinet`;
|
|
4198
|
+
openPortalLink.textContent = `Личный кабинет (${v.replace(/^https?:\/\//, '')}) ↗`;
|
|
4199
|
+
}
|
|
4191
4200
|
});
|
|
4192
4201
|
|
|
4193
|
-
const applyActivatedState = async () => {
|
|
4194
|
-
state.config = await api('/api/config');
|
|
4195
|
-
updateTabBadges();
|
|
4196
|
-
await renderCloudPane();
|
|
4197
|
-
await loadBrokers().catch(() => {});
|
|
4198
|
-
await loadLlm().catch(() => {});
|
|
4199
|
-
if (isOwnerUser()) await loadRuns().catch(() => {});
|
|
4200
|
-
if (isLicensed()) refreshForecastIfStale();
|
|
4201
|
-
else loadForecast(false, true);
|
|
4202
|
-
};
|
|
4203
|
-
|
|
4204
4202
|
box.querySelector('#btn-lic-refresh')?.addEventListener('click', async () => {
|
|
4205
4203
|
try {
|
|
4206
|
-
const pUrl = portalInput?.value.trim() || portalUrl;
|
|
4204
|
+
const pUrl = (portalInput?.value.trim() || portalUrl).replace(/\/+$/, '');
|
|
4207
4205
|
const r = await post('/api/local/refresh-license', { portalUrl: pUrl });
|
|
4208
|
-
toast(`✓ Лицензии
|
|
4206
|
+
toast(`✓ Лицензии проверены: ${r.state?.planName || 'активна'}`);
|
|
4209
4207
|
await applyActivatedState();
|
|
4210
4208
|
} catch (e) { toast(e.message, 'error'); }
|
|
4211
4209
|
});
|
|
4212
4210
|
|
|
4213
4211
|
box.querySelector('#btn-lic-detach')?.addEventListener('click', async () => {
|
|
4214
|
-
if (!confirm('
|
|
4212
|
+
if (!confirm('Отвязать токен с этого терминала? Торговля и живой радар сразу остановятся.')) return;
|
|
4215
4213
|
try {
|
|
4216
4214
|
await post('/api/local/detach-license', {});
|
|
4217
|
-
toast('
|
|
4215
|
+
toast('Токен отвязан — торговля отключена');
|
|
4218
4216
|
await applyActivatedState();
|
|
4219
4217
|
} catch (e) { toast(e.message, 'error'); }
|
|
4220
4218
|
});
|
|
4221
4219
|
|
|
4222
4220
|
box.querySelector('#form-lic-bind')?.addEventListener('submit', async (e) => {
|
|
4223
4221
|
e.preventDefault();
|
|
4224
|
-
const
|
|
4225
|
-
const pUrl = portalInput?.value.trim() || portalUrl;
|
|
4226
|
-
const pickedSlot = box.querySelector('input[name="pick_slot_id"]:checked')?.value || '';
|
|
4222
|
+
const tok = e.target.elements.token.value.trim();
|
|
4223
|
+
const pUrl = (portalInput?.value.trim() || portalUrl).replace(/\/+$/, '');
|
|
4227
4224
|
try {
|
|
4228
|
-
const r = await post('/api/local/activate', {
|
|
4229
|
-
|
|
4230
|
-
portalUrl: pUrl,
|
|
4231
|
-
slotId: pickedSlot,
|
|
4232
|
-
});
|
|
4233
|
-
if (r.chooseSlot && Array.isArray(r.slots) && r.slots.length) {
|
|
4234
|
-
const slotBox = box.querySelector('#lic-slot-choice');
|
|
4235
|
-
slotBox.hidden = false;
|
|
4236
|
-
slotBox.innerHTML = `<div class="small" style="font-weight:600">Выберите свободную лицензию для этого терминала и нажмите «Загрузить лицензии» ещё раз:</div>`
|
|
4237
|
-
+ r.slots.map((s, i) => `<label class="row-gap small" style="padding:6px 10px;border:1px solid var(--line);border-radius:7px;cursor:pointer">
|
|
4238
|
-
<input type="radio" name="pick_slot_id" value="${esc(s.id)}" ${i === 0 ? 'checked' : ''}>
|
|
4239
|
-
<span><b>${esc(s.label || s.id)}</b> · ${esc(s.planName)} (${s.perpetual ? 'бессрочно' : `${s.totalRemainingDays || 0} сут.`})</span>
|
|
4240
|
-
</label>`).join('');
|
|
4241
|
-
toast('Выберите нужный слот лицензии в списке');
|
|
4242
|
-
return;
|
|
4243
|
-
}
|
|
4244
|
-
f.token.value = '';
|
|
4245
|
-
toast(`✓ Лицензия «${r.state?.planName || ''}» успешно загружена и активирована!`);
|
|
4225
|
+
const r = await post('/api/local/activate', { token: tok, portalUrl: pUrl });
|
|
4226
|
+
toast(`✓ Лицензия «${r.state?.planName || ''}» установлена!`);
|
|
4246
4227
|
await applyActivatedState();
|
|
4247
4228
|
} catch (err) { toast(err.message, 'error'); }
|
|
4248
4229
|
});
|
|
4230
|
+
}
|
|
4249
4231
|
|
|
4250
|
-
|
|
4251
|
-
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
});
|
|
4232
|
+
// ---------- Отдельный чистый раздел: Уведомления (Telegram / MAX) и пароль удалённого доступа (VPS) ----------
|
|
4233
|
+
async function renderAccessPane() {
|
|
4234
|
+
const box = $('#access-pane');
|
|
4235
|
+
if (!box) return;
|
|
4236
|
+
const me = await api('/api/auth/me').catch(() => state.config || {});
|
|
4237
|
+
state.config = { ...(state.config || {}), ...me };
|
|
4238
|
+
const lic = me.license || me.user?.license || {};
|
|
4239
|
+
const nt = me.notify || {};
|
|
4240
|
+
const portalUrl = lic.portalUrl || 'https://neuroalpha.odant.org';
|
|
4260
4241
|
|
|
4261
|
-
box.
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
4242
|
+
box.innerHTML = `
|
|
4243
|
+
<div class="lic-card">
|
|
4244
|
+
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:10px">
|
|
4245
|
+
<div style="font-size:15.5px;font-weight:700">💬 Уведомления и диалог со Стратегом в Telegram и MAX</div>
|
|
4246
|
+
<button type="button" id="btn-notify-test">Отправить тест уведомления</button>
|
|
4247
|
+
</div>
|
|
4248
|
+
<p class="muted small" style="margin:0 0 14px">
|
|
4249
|
+
По умолчанию уведомления и кнопки подтверждения заявок работают через официальных ботов портала (привяжите ваш мессенджер во вкладке «Личный кабинет → Telegram / MAX» на <a href="${esc(portalUrl)}/#cabinet" target="_blank" rel="noopener">${esc(portalUrl.replace(/^https?:\/\//, ''))}</a>). Ниже при желании можно подключить собственного личного бота напрямую.
|
|
4250
|
+
</p>
|
|
4251
|
+
<form id="direct-notify-form" style="display:grid;gap:10px;padding-top:12px;border-top:1px solid var(--line)">
|
|
4252
|
+
<label class="row-gap small"><input type="checkbox" name="usePortalRelay" ${nt.usePortalRelay !== false ? 'checked' : ''}> Использовать ботов портала, если прямой токен бота не заполнен</label>
|
|
4253
|
+
<div style="display:grid;grid-template-columns:1fr 220px;gap:10px">
|
|
4254
|
+
<input name="tgToken" type="text" class="secret-input" autocomplete="off" placeholder="${nt.hasDirectTg ? 'Токен личного Telegram-бота сохранён' : 'Токен личного Telegram-бота (@BotFather)'}">
|
|
4255
|
+
<input name="tgChatId" value="${esc(nt.tgChatId || '')}" placeholder="Telegram Chat ID">
|
|
4256
|
+
</div>
|
|
4257
|
+
<div style="display:grid;grid-template-columns:1fr 220px;gap:10px">
|
|
4258
|
+
<input name="maxToken" type="text" class="secret-input" autocomplete="off" placeholder="${nt.hasDirectMax ? 'Токен личного MAX-бота сохранён' : 'Токен личного MAX-бота'}">
|
|
4259
|
+
<input name="maxChatId" value="${esc(nt.maxChatId || '')}" placeholder="MAX Chat ID">
|
|
4260
|
+
</div>
|
|
4261
|
+
<div><button type="submit" class="primary">Сохранить настройки уведомлений</button></div>
|
|
4262
|
+
</form>
|
|
4263
|
+
</div>
|
|
4264
|
+
|
|
4265
|
+
<div class="lic-card">
|
|
4266
|
+
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:8px">
|
|
4267
|
+
<div style="font-size:15.5px;font-weight:700">🔐 Пароль для удалённого доступа (при работе на VPS)</div>
|
|
4268
|
+
${!me.isLoopback ? '<button type="button" id="btn-term-logout" class="btn-unlink-soft" style="height:32px">Выйти из сессии</button>' : ''}
|
|
4269
|
+
</div>
|
|
4270
|
+
<p class="muted small" style="margin:0 0 14px">
|
|
4271
|
+
С локального компьютера (<code>localhost</code>) терминал открывается без пароля. При входе по внешнему IP сервер запрашивает ваш токен <code>na_tok_…</code> либо заданный здесь локальный пароль (статус: ${lic.hasLocalPassword ? '<b class="pos">установлен ✓</b>' : 'не задан'}).
|
|
4272
|
+
</p>
|
|
4273
|
+
<form id="form-local-pass" class="row-gap" style="flex-wrap:wrap">
|
|
4274
|
+
<input name="password" type="password" autocomplete="new-password" placeholder="${lic.hasLocalPassword ? 'Новый пароль (пусто — сбросить)' : 'Задать пароль (от 6 симв.)'}" style="width:260px">
|
|
4275
|
+
<button type="submit">Сохранить пароль</button>
|
|
4276
|
+
</form>
|
|
4277
|
+
</div>
|
|
4278
|
+
`;
|
|
4265
4279
|
|
|
4266
4280
|
box.querySelector('#btn-notify-test')?.addEventListener('click', async () => {
|
|
4267
|
-
try {
|
|
4268
|
-
|
|
4269
|
-
toast('✓ Тестовое уведомление доставлено');
|
|
4270
|
-
} catch (e) { toast(e.message, 'error'); }
|
|
4281
|
+
try { await post('/api/notify/test'); toast('✓ Тестовое уведомление отправлено'); }
|
|
4282
|
+
catch (e) { toast(e.message, 'error'); }
|
|
4271
4283
|
});
|
|
4272
4284
|
|
|
4273
4285
|
box.querySelector('#direct-notify-form')?.addEventListener('submit', async (e) => {
|
|
@@ -4282,9 +4294,25 @@ async function renderCloudPane() {
|
|
|
4282
4294
|
maxChatId: f.maxChatId.value,
|
|
4283
4295
|
});
|
|
4284
4296
|
toast('✓ Настройки уведомлений сохранены');
|
|
4285
|
-
|
|
4297
|
+
renderAccessPane();
|
|
4286
4298
|
} catch (err) { toast(err.message, 'error'); }
|
|
4287
4299
|
});
|
|
4300
|
+
|
|
4301
|
+
box.querySelector('#form-local-pass')?.addEventListener('submit', async (e) => {
|
|
4302
|
+
e.preventDefault();
|
|
4303
|
+
const pw = e.target.elements.password.value;
|
|
4304
|
+
try {
|
|
4305
|
+
const r = await post('/api/local/password', { password: pw });
|
|
4306
|
+
e.target.reset();
|
|
4307
|
+
toast(r.hasLocalPassword ? '✓ Пароль сохранён' : 'Пароль сброшен');
|
|
4308
|
+
renderAccessPane();
|
|
4309
|
+
} catch (err) { toast(err.message, 'error'); }
|
|
4310
|
+
});
|
|
4311
|
+
|
|
4312
|
+
box.querySelector('#btn-term-logout')?.addEventListener('click', async () => {
|
|
4313
|
+
await post('/api/auth/logout').catch(() => {});
|
|
4314
|
+
location.href = '/';
|
|
4315
|
+
});
|
|
4288
4316
|
}
|
|
4289
4317
|
|
|
4290
4318
|
// ---------- тема (тёмная / светлая) ----------
|
|
@@ -4860,7 +4888,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
4860
4888
|
showMainSec('cloud');
|
|
4861
4889
|
} else if (urlParams.get('run') && isOwnerUser()) {
|
|
4862
4890
|
showMainSec('overview');
|
|
4863
|
-
} else if (savedNav.mainSec && ['brokers', 'forecast', 'news', 'overview', 'cloud', 'llm'].includes(savedNav.mainSec)) {
|
|
4891
|
+
} else if (savedNav.mainSec && ['brokers', 'forecast', 'news', 'overview', 'cloud', 'access', 'llm'].includes(savedNav.mainSec)) {
|
|
4864
4892
|
showMainSec(savedNav.mainSec);
|
|
4865
4893
|
} else {
|
|
4866
4894
|
showMainSec('brokers');
|
package/public/index.html
CHANGED
|
@@ -93,6 +93,7 @@
|
|
|
93
93
|
<div class="ssep"></div>
|
|
94
94
|
<button type="button" data-sec="overview" id="nnav-overview" hidden>Эксперименты</button>
|
|
95
95
|
<button type="button" data-sec="cloud" id="snav-cloud">Лицензия</button>
|
|
96
|
+
<button type="button" data-sec="access" id="snav-access">Уведомления и доступ</button>
|
|
96
97
|
<button type="button" data-sec="llm" id="snav-llm">Модели ИИ</button>
|
|
97
98
|
</aside>
|
|
98
99
|
<div class="spane">
|
|
@@ -249,8 +250,11 @@
|
|
|
249
250
|
</section>
|
|
250
251
|
|
|
251
252
|
<section class="ssec" data-sec="cloud" id="cloud-box" hidden>
|
|
252
|
-
<div class="
|
|
253
|
-
|
|
253
|
+
<div class="lic-page-wrap" id="cloud-pane"></div>
|
|
254
|
+
</section>
|
|
255
|
+
|
|
256
|
+
<section class="ssec" data-sec="access" id="access-box" hidden>
|
|
257
|
+
<div class="lic-page-wrap" id="access-pane"></div>
|
|
254
258
|
</section>
|
|
255
259
|
|
|
256
260
|
<section class="ssec" data-sec="llm" id="llm-box" hidden>
|
package/public/styles.css
CHANGED
|
@@ -98,6 +98,62 @@ body.light .hdr-btn.lic-err {
|
|
|
98
98
|
border-color: #dc2626;
|
|
99
99
|
color: #b91c1c;
|
|
100
100
|
}
|
|
101
|
+
summary:focus, summary:focus-visible { outline: none; }
|
|
102
|
+
.lic-page-wrap {
|
|
103
|
+
max-width: 860px;
|
|
104
|
+
margin: 6px auto 28px;
|
|
105
|
+
display: grid;
|
|
106
|
+
gap: 18px;
|
|
107
|
+
}
|
|
108
|
+
.lic-card {
|
|
109
|
+
background: var(--panel);
|
|
110
|
+
border: 1px solid var(--line);
|
|
111
|
+
border-radius: 14px;
|
|
112
|
+
padding: 22px 24px;
|
|
113
|
+
}
|
|
114
|
+
.lic-stats-grid {
|
|
115
|
+
display: grid;
|
|
116
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
117
|
+
gap: 12px;
|
|
118
|
+
margin: 16px 0;
|
|
119
|
+
}
|
|
120
|
+
@media (max-width: 780px) { .lic-stats-grid { grid-template-columns: 1fr; } }
|
|
121
|
+
.lic-stat-tile {
|
|
122
|
+
border: 1px solid var(--line);
|
|
123
|
+
border-radius: 10px;
|
|
124
|
+
padding: 12px 14px;
|
|
125
|
+
background: var(--bg);
|
|
126
|
+
}
|
|
127
|
+
.lic-stat-lbl { font-size: 11.5px; color: var(--muted); margin-bottom: 4px; }
|
|
128
|
+
.lic-stat-val { font-size: 16px; font-weight: 700; color: var(--text); }
|
|
129
|
+
.lic-stat-sub { font-size: 12px; color: var(--muted); margin-top: 3px; }
|
|
130
|
+
.lic-token-row {
|
|
131
|
+
display: grid;
|
|
132
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
133
|
+
gap: 10px;
|
|
134
|
+
align-items: center;
|
|
135
|
+
}
|
|
136
|
+
.lic-token-row input {
|
|
137
|
+
height: 38px;
|
|
138
|
+
padding: 8px 12px;
|
|
139
|
+
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
|
140
|
+
font-size: 13px;
|
|
141
|
+
background: var(--bg);
|
|
142
|
+
}
|
|
143
|
+
button.btn-unlink-soft {
|
|
144
|
+
height: 38px;
|
|
145
|
+
padding: 0 16px;
|
|
146
|
+
background: rgba(244, 63, 94, 0.1);
|
|
147
|
+
border-color: rgba(244, 63, 94, 0.35);
|
|
148
|
+
color: #e11d48;
|
|
149
|
+
font-weight: 600;
|
|
150
|
+
}
|
|
151
|
+
body:not(.light) button.btn-unlink-soft { color: #fb7185; }
|
|
152
|
+
button.btn-unlink-soft:hover:not(:disabled) {
|
|
153
|
+
background: rgba(244, 63, 94, 0.18);
|
|
154
|
+
border-color: #e11d48;
|
|
155
|
+
color: #e11d48;
|
|
156
|
+
}
|
|
101
157
|
.brand { display: flex; gap: 8px; align-items: center; }
|
|
102
158
|
.logo { color: var(--accent); font-size: 18px; }
|
|
103
159
|
.hright { display: flex; gap: 8px; align-items: center; }
|
package/server/auth/index.js
CHANGED
|
@@ -215,6 +215,8 @@ export function verifyLicenseCertificate(licenseToken, publicKeyPem, expectedIns
|
|
|
215
215
|
export function getTerminalLicenseState() {
|
|
216
216
|
const stored = kvGet('terminalLicense') || null;
|
|
217
217
|
const localPassHash = kvGet('terminalLocalPassHash') || '';
|
|
218
|
+
const rawTok = decryptSecret(stored?.cabinetTokenEnc || '');
|
|
219
|
+
const maskedToken = rawTok ? `${rawTok.slice(0, 22)}…${rawTok.slice(-8)}` : '';
|
|
218
220
|
if (!stored?.licenseToken || !stored?.publicKeyPem) {
|
|
219
221
|
return {
|
|
220
222
|
activated: false,
|
|
@@ -229,7 +231,8 @@ export function getTerminalLicenseState() {
|
|
|
229
231
|
licId: '',
|
|
230
232
|
label: '',
|
|
231
233
|
portalUrl: stored?.portalUrl || DEFAULT_CLOUD_URL,
|
|
232
|
-
hasCabinetToken: Boolean(
|
|
234
|
+
hasCabinetToken: Boolean(rawTok),
|
|
235
|
+
maskedToken,
|
|
233
236
|
hasLocalPassword: Boolean(localPassHash),
|
|
234
237
|
installId: getInstallId(),
|
|
235
238
|
slots: Array.isArray(stored?.allSlots) ? stored.allSlots : [],
|
|
@@ -256,7 +259,8 @@ export function getTerminalLicenseState() {
|
|
|
256
259
|
licId: p.licId || stored.slotId || '',
|
|
257
260
|
label: p.label || '',
|
|
258
261
|
portalUrl: stored.portalUrl || p.portalUrl || DEFAULT_CLOUD_URL,
|
|
259
|
-
hasCabinetToken: Boolean(
|
|
262
|
+
hasCabinetToken: Boolean(rawTok),
|
|
263
|
+
maskedToken,
|
|
260
264
|
hasLocalPassword: Boolean(localPassHash),
|
|
261
265
|
installId: getInstallId(),
|
|
262
266
|
lastFetchedAt: stored.fetchedAt || 0,
|
|
@@ -277,7 +281,8 @@ export function getTerminalLicenseState() {
|
|
|
277
281
|
licId: '',
|
|
278
282
|
label: '',
|
|
279
283
|
portalUrl: stored.portalUrl || DEFAULT_CLOUD_URL,
|
|
280
|
-
hasCabinetToken: Boolean(
|
|
284
|
+
hasCabinetToken: Boolean(rawTok),
|
|
285
|
+
maskedToken,
|
|
281
286
|
hasLocalPassword: Boolean(localPassHash),
|
|
282
287
|
installId: getInstallId(),
|
|
283
288
|
slots: Array.isArray(stored.allSlots) ? stored.allSlots : [],
|
package/server/llm/providers.js
CHANGED
|
@@ -177,15 +177,6 @@ function init() {
|
|
|
177
177
|
ready = true;
|
|
178
178
|
if (kvGet('llmProviders') === null) kvSet('llmProviders', []);
|
|
179
179
|
ensureEnv();
|
|
180
|
-
// разовая миграция: подключения, созданные до настройки сертификата в интерфейсе, получают прежний путь к сертификату НУЦ (только как данные — можно изменить или удалить)
|
|
181
|
-
if (!kvGet('gigaCaMigrated')) {
|
|
182
|
-
const list = kvGet('llmProviders', []);
|
|
183
|
-
const cert = path.resolve(ROOT, '../work/#system/certs/russian_trusted_root_ca.pem');
|
|
184
|
-
if (fs.existsSync(cert)) for (const p of list) if (p.protocol === 'gigachat' && !p.caFile) p.caFile = cert;
|
|
185
|
-
kvSet('llmProviders', list);
|
|
186
|
-
kvSet('gigaCaMigrated', true);
|
|
187
|
-
}
|
|
188
|
-
ensureEnv();
|
|
189
180
|
ensureCloudDefault();
|
|
190
181
|
const list = kvGet('llmProviders', []);
|
|
191
182
|
if (!kvGet('llmSelected') && list.length) {
|
package/server/tls-ca.js
CHANGED
|
@@ -10,7 +10,6 @@ const CANDIDATES = [
|
|
|
10
10
|
process.env.BROKER_CA_FILE,
|
|
11
11
|
process.env.GIGACHAT_CA_FILE,
|
|
12
12
|
path.join(ROOT, 'config', 'certs', 'russian_trusted_root_ca.pem'),
|
|
13
|
-
path.resolve(ROOT, '../work/#system/certs/russian_trusted_root_ca.pem'),
|
|
14
13
|
].filter(Boolean);
|
|
15
14
|
|
|
16
15
|
let russianRootPem = '';
|