slotlock 0.7.0 → 0.8.0
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/CHANGELOG.md +44 -0
- package/assistant.d.ts +30 -1
- package/assistant.js +369 -56
- package/dashboard-assistant-ui.d.ts +21 -0
- package/dashboard-assistant-ui.js +438 -0
- package/dashboard-markdown.d.ts +2 -0
- package/dashboard-markdown.js +157 -0
- package/dashboard-settings-ui.d.ts +1 -0
- package/dashboard-settings-ui.js +78 -0
- package/dashboard.d.ts +29 -5
- package/dashboard.js +900 -315
- package/package.json +1 -1
- package/slotlock.manifest.json +1 -1
package/dashboard.js
CHANGED
|
@@ -25,6 +25,9 @@ import { SLOTLOCK_DASHBOARD_FORM_LIMIT } from './ddl.js';
|
|
|
25
25
|
import { ignoreRejection } from './hooks.js';
|
|
26
26
|
import { SLOTLOCK_BOOKING_RULE_LIMITS } from './booking-rules.js';
|
|
27
27
|
import { SLOTLOCK_DASHBOARD_ICON_SVG } from './dashboard-icon.js';
|
|
28
|
+
import { SLOTLOCK_SETTINGS_CSS } from './dashboard-settings-ui.js';
|
|
29
|
+
import { renderSlotlockMarkdown } from './dashboard-markdown.js';
|
|
30
|
+
import { SLOTLOCK_ASSISTANT_CSS, slotlockAssistantActionVerb, slotlockAssistantScript, slotlockAssistantStepLabel, } from './dashboard-assistant-ui.js';
|
|
28
31
|
import { SLOTLOCK_PLAN_LIMITS, SLOTLOCK_PLAN_QUOTAS } from './plans.js';
|
|
29
32
|
import { SLOTLOCK_MAX_GROUPS_PER_RESOURCE, SLOTLOCK_MAX_LOCATION_DEPTH, SLOTLOCK_MAX_LOCATIONS, SLOTLOCK_MAX_RESOURCE_GROUPS, SLOTLOCK_RESOURCE_KINDS, SLOTLOCK_RESOURCE_PROFILE_LIMITS, } from './resource-profile.js';
|
|
30
33
|
import { slotlockQuotaPeriod } from './quotas.js';
|
|
@@ -89,6 +92,7 @@ const POST_ROUTES = new Set([
|
|
|
89
92
|
'/teams/enable',
|
|
90
93
|
'/teams/disable',
|
|
91
94
|
'/assistant',
|
|
95
|
+
'/assistant/stream',
|
|
92
96
|
'/assistant/approve',
|
|
93
97
|
'/assistant/dismiss',
|
|
94
98
|
'/assistant/reset',
|
|
@@ -132,7 +136,7 @@ const KEY_EXPIRY_DAYS = Object.freeze({
|
|
|
132
136
|
* still sends no referrer to any other site, so the callback URL's code never leaves.
|
|
133
137
|
*/
|
|
134
138
|
const REFERRER_POLICY = 'same-origin';
|
|
135
|
-
const CONTENT_SECURITY_POLICY = "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'";
|
|
139
|
+
const CONTENT_SECURITY_POLICY = "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'";
|
|
136
140
|
/** The policy, with billing's origins allowed as form targets so Upgrade and Manage can redirect. */
|
|
137
141
|
function policyFor(billing) {
|
|
138
142
|
if (!billing)
|
|
@@ -660,6 +664,20 @@ const CHANGE_LABELS = Object.freeze({
|
|
|
660
664
|
'approval.expired': { text: 'Approval not decided in time', tone: 'bad' },
|
|
661
665
|
'webhook.disabled': { text: 'Webhook disabled', tone: 'bad' },
|
|
662
666
|
'usage.threshold_reached': { text: 'Monthly quota nearly or fully used', tone: 'bad' },
|
|
667
|
+
'usage.period_closed': { text: 'Month of use closed', tone: 'neutral' },
|
|
668
|
+
'resource.created': { text: 'Resource added', tone: 'good' },
|
|
669
|
+
'resource.updated': { text: 'Resource changed', tone: 'neutral' },
|
|
670
|
+
'resource.archived': { text: 'Resource archived', tone: 'neutral' },
|
|
671
|
+
'resource.restored': { text: 'Resource restored', tone: 'good' },
|
|
672
|
+
'resource.deleted': { text: 'Resource deleted', tone: 'bad' },
|
|
673
|
+
'key.created': { text: 'API key created', tone: 'good' },
|
|
674
|
+
'key.rotated': { text: 'API key rotated', tone: 'neutral' },
|
|
675
|
+
'key.revoked': { text: 'API key revoked', tone: 'bad' },
|
|
676
|
+
'webhook.created': { text: 'Webhook added', tone: 'good' },
|
|
677
|
+
'webhook.enabled': { text: 'Webhook enabled', tone: 'good' },
|
|
678
|
+
'webhook.deleted': { text: 'Webhook deleted', tone: 'neutral' },
|
|
679
|
+
'coverage.recorded': { text: 'Calendar synced', tone: 'neutral' },
|
|
680
|
+
'coverage.deleted': { text: 'Calendar sync removed', tone: 'neutral' },
|
|
663
681
|
});
|
|
664
682
|
const zoned = (zone, parts) => {
|
|
665
683
|
try {
|
|
@@ -765,6 +783,10 @@ function formatDate(date) {
|
|
|
765
783
|
return '—';
|
|
766
784
|
return `${date.toISOString().slice(0, 16).replace('T', ' ')} UTC`;
|
|
767
785
|
}
|
|
786
|
+
/** A UTC day as people write it: `9 Dec 2026`. */
|
|
787
|
+
function dayOf(date) {
|
|
788
|
+
return zoned('UTC', { day: 'numeric', month: 'short', year: 'numeric' }).format(date);
|
|
789
|
+
}
|
|
768
790
|
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
769
791
|
/** A table's date: the day (`19 Oct 2026`), with the full UTC time on hover and for machines. */
|
|
770
792
|
function dateCell(date) {
|
|
@@ -920,6 +942,20 @@ export function createSlotlockDashboard(options) {
|
|
|
920
942
|
}
|
|
921
943
|
// Teams on Cloud: organizations turned on in the database, beside every person's own tenant.
|
|
922
944
|
const teams = options.organizations === 'enabled' ? (options.teams ?? null) : null;
|
|
945
|
+
// Plans whether or not billing sells them; billing's own reader when only it is given.
|
|
946
|
+
const plans = options.plans ??
|
|
947
|
+
(options.billing
|
|
948
|
+
? {
|
|
949
|
+
plan: options.billing.plan,
|
|
950
|
+
...(options.billing.enrol ? { enrol: options.billing.enrol } : {}),
|
|
951
|
+
contact: options.billing.contact,
|
|
952
|
+
}
|
|
953
|
+
: undefined);
|
|
954
|
+
if (plans?.contact) {
|
|
955
|
+
const protocol = new URL(plans.contact).protocol;
|
|
956
|
+
if (protocol !== 'https:' && protocol !== 'mailto:')
|
|
957
|
+
throw new Error('plans.contact must be an https or mailto URL');
|
|
958
|
+
}
|
|
923
959
|
if (options.organizations === 'enabled' && !teams) {
|
|
924
960
|
throw new Error('Slotlock dashboard organizations "enabled" needs the teams store');
|
|
925
961
|
}
|
|
@@ -1106,9 +1142,7 @@ export function createSlotlockDashboard(options) {
|
|
|
1106
1142
|
...(assistantStore()
|
|
1107
1143
|
? [{ section: 'assistant', label: 'Assistant', path: '/assistant', icon: ICONS.assistant }]
|
|
1108
1144
|
: []),
|
|
1109
|
-
...(
|
|
1110
|
-
? [{ section: 'plan', label: 'Plan', path: '/plan', icon: ICONS.plan }]
|
|
1111
|
-
: []),
|
|
1145
|
+
...(plans ? [{ section: 'plan', label: 'Usage', path: '/plan', icon: ICONS.plan }] : []),
|
|
1112
1146
|
...(teams ? [{ section: 'teams', label: 'Teams', path: '/teams', icon: ICONS.teams }] : []),
|
|
1113
1147
|
{ section: 'settings', label: 'Settings', path: '/settings', icon: ICONS.settings },
|
|
1114
1148
|
];
|
|
@@ -1177,13 +1211,19 @@ ${live}
|
|
|
1177
1211
|
</body>
|
|
1178
1212
|
</html>`;
|
|
1179
1213
|
}
|
|
1180
|
-
|
|
1214
|
+
// A notice is an alert only when the page answers with an error: a done or saved message is a
|
|
1215
|
+
// status, so success is never painted in the error colour.
|
|
1216
|
+
const noticeOf = (notice, status = 200) => notice
|
|
1217
|
+
? status >= 400
|
|
1218
|
+
? html `<p class="notice" role="alert">${notice}</p>`
|
|
1219
|
+
: html `<p class="notice done" role="status">${notice}</p>`
|
|
1220
|
+
: html ``;
|
|
1181
1221
|
function signedOutPage(status, notice, cookies = []) {
|
|
1182
1222
|
const content = html `<section class="sign-in">
|
|
1183
1223
|
<span class="sign-in-mark">${ICONS.mark}</span>
|
|
1184
1224
|
<h1>Sign in to ${brandName}</h1>
|
|
1185
1225
|
<p>Create keys for your agents, add what they book, and watch what they do.</p>
|
|
1186
|
-
${noticeOf(notice)}
|
|
1226
|
+
${noticeOf(notice, status)}
|
|
1187
1227
|
<a class="button primary wide" href="${root}/sign-in">${ICONS.github}Continue with GitHub</a>${google ? html `<a class="button wide" href="${root}/sign-in?with=google">${ICONS.google}Continue with Google</a>` : html ``}
|
|
1188
1228
|
${options.termsUrl
|
|
1189
1229
|
? html `<p class="fine">By continuing you agree to the <a href="${options.termsUrl}">Terms of Service</a>.${options.privacyUrl
|
|
@@ -1273,74 +1313,205 @@ ${options.termsUrl
|
|
|
1273
1313
|
}
|
|
1274
1314
|
return html `${[...groups.values()].map((group) => html `<h3 class="day">${group.label}</h3><ul class="slots">${group.rows}</ul>`)}${more ? html `<p class="fine">Showing the first ${String(AGENDA_SHOWN)}.</p>` : html ``}`;
|
|
1275
1315
|
}
|
|
1316
|
+
/**
|
|
1317
|
+
* What each change says in the feed, as a sentence: who did it (`verb` then the resource), or what
|
|
1318
|
+
* happened (`passive`, the resource in place of {X}). Every change type the feed records has one,
|
|
1319
|
+
* so the feed never shows a raw type such as resource.updated.
|
|
1320
|
+
*/
|
|
1321
|
+
const ACTIVITY = Object.freeze({
|
|
1322
|
+
'hold.created': { verb: 'held' },
|
|
1323
|
+
'hold.updated': { verb: 'changed the hold on' },
|
|
1324
|
+
'hold.confirmed': { verb: 'booked', after: ' from a hold' },
|
|
1325
|
+
'hold.released': { verb: 'released the hold on' },
|
|
1326
|
+
'hold.expired': { passive: 'The hold on {X} expired' },
|
|
1327
|
+
'reservation.created': { verb: 'booked' },
|
|
1328
|
+
'reservation.updated': { verb: 'moved a booking on' },
|
|
1329
|
+
'reservation.cancelled': { verb: 'cancelled a booking on' },
|
|
1330
|
+
'reservation.checked_in': { verb: 'checked in to' },
|
|
1331
|
+
'reservation.no_show': { passive: '{X} was released after a no-show' },
|
|
1332
|
+
'event.created': { verb: 'added an event on' },
|
|
1333
|
+
'event.updated': { verb: 'changed an event on' },
|
|
1334
|
+
'event.cancelled': { verb: 'cancelled an event on' },
|
|
1335
|
+
'event.timezone_shifted': { passive: 'An event on {X} moved with a time zone change' },
|
|
1336
|
+
'event.timezone_conflict': { passive: 'An event on {X} clashes after a time zone change' },
|
|
1337
|
+
'coverage.stale': { passive: 'The calendar sync for {X} is behind' },
|
|
1338
|
+
'coverage.recorded': { passive: 'The calendar for {X} synced' },
|
|
1339
|
+
'coverage.deleted': { verb: 'removed the calendar sync for' },
|
|
1340
|
+
'waitlist.offered': { passive: 'A waitlist offer went out for {X}' },
|
|
1341
|
+
'waitlist.expired': { passive: 'A waitlist offer for {X} lapsed' },
|
|
1342
|
+
'approval.requested': { passive: 'A booking on {X} waits for approval' },
|
|
1343
|
+
'approval.granted': { verb: 'approved a booking on' },
|
|
1344
|
+
'approval.declined': { verb: 'declined a booking on' },
|
|
1345
|
+
'approval.expired': { passive: 'A booking on {X} was not approved in time' },
|
|
1346
|
+
'resource.created': { verb: 'added' },
|
|
1347
|
+
'resource.updated': { verb: 'edited' },
|
|
1348
|
+
'resource.archived': { verb: 'archived' },
|
|
1349
|
+
'resource.restored': { verb: 'restored' },
|
|
1350
|
+
'resource.deleted': { verb: 'deleted' },
|
|
1351
|
+
'key.created': { verb: 'created an API key' },
|
|
1352
|
+
'key.rotated': { verb: 'rotated an API key' },
|
|
1353
|
+
'key.revoked': { verb: 'revoked an API key' },
|
|
1354
|
+
'webhook.created': { verb: 'added a webhook' },
|
|
1355
|
+
'webhook.enabled': { verb: 'turned a webhook back on' },
|
|
1356
|
+
'webhook.disabled': { passive: 'A webhook was turned off' },
|
|
1357
|
+
'webhook.deleted': { verb: 'deleted a webhook' },
|
|
1358
|
+
'usage.period_closed': { passive: 'A month of use closed' },
|
|
1359
|
+
});
|
|
1360
|
+
/** Changes about a resource, which name it; the rest stand alone. */
|
|
1361
|
+
const ABOUT_A_RESOURCE = /^(hold|reservation|event|coverage|waitlist|approval|resource)\./;
|
|
1276
1362
|
function activityPanel(changes, byId, keys, session) {
|
|
1277
1363
|
if (changes.length === 0) {
|
|
1278
1364
|
return html `<p class="empty">No activity yet. What agents book, move and cancel shows here.</p>`;
|
|
1279
1365
|
}
|
|
1280
1366
|
const keyNames = new Map(keys.map((key) => [`api_key:${key.id}`, key.name]));
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
?
|
|
1285
|
-
:
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
(
|
|
1289
|
-
?
|
|
1290
|
-
: /^
|
|
1291
|
-
?
|
|
1292
|
-
: /^google:/.test(
|
|
1293
|
-
?
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
?
|
|
1297
|
-
:
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1367
|
+
const whoOf = (actor) => actor === null
|
|
1368
|
+
? brandName
|
|
1369
|
+
: actor === personOf(session)
|
|
1370
|
+
? 'You'
|
|
1371
|
+
: (keyNames.get(actor) ??
|
|
1372
|
+
(actor.startsWith('api_key:')
|
|
1373
|
+
? 'A deleted key'
|
|
1374
|
+
: /^github:\d+$/.test(actor)
|
|
1375
|
+
? `GitHub user ${actor.slice(7)}`
|
|
1376
|
+
: /^google:/.test(actor)
|
|
1377
|
+
? 'A Google user'
|
|
1378
|
+
: /^dashboard-assistant:(github|google):/.test(actor)
|
|
1379
|
+
? actor === assistantActor(session)
|
|
1380
|
+
? `Assistant for ${shownAs(session)}`
|
|
1381
|
+
: actor.startsWith('dashboard-assistant:google:')
|
|
1382
|
+
? 'Assistant for a Google user'
|
|
1383
|
+
: `Assistant for GitHub user ${actor.slice(27)}`
|
|
1384
|
+
: actor.slice(0, 60)));
|
|
1385
|
+
// Repeats in a row (one actor, one change, one resource) read as one line with a count.
|
|
1386
|
+
const runs = [];
|
|
1387
|
+
for (const change of changes) {
|
|
1388
|
+
const last = runs.at(-1);
|
|
1389
|
+
if (last && last.change.actor === change.actor && last.change.type === change.type && last.change.resourceId === change.resourceId) {
|
|
1390
|
+
last.times += 1;
|
|
1391
|
+
}
|
|
1392
|
+
else {
|
|
1393
|
+
runs.push({ change, times: 1 });
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
return html `<ol class="feed">${runs.map(({ change, times }) => {
|
|
1397
|
+
const tone = CHANGE_LABELS[change.type]?.tone ?? 'neutral';
|
|
1398
|
+
const where = change.resourceId ? resourceName(byId.get(change.resourceId), change.resourceId) : null;
|
|
1399
|
+
const object = where ?? (ABOUT_A_RESOURCE.test(change.type) ? 'a resource' : null);
|
|
1400
|
+
const words = ACTIVITY[change.type];
|
|
1401
|
+
let sentence;
|
|
1402
|
+
if (change.type === 'usage.threshold_reached') {
|
|
1403
|
+
const metric = typeof change.data.metric === 'string' ? change.data.metric : 'use';
|
|
1404
|
+
const percent = typeof change.data.percent === 'number' ? change.data.percent : null;
|
|
1405
|
+
sentence = html `${percent === null ? 'A monthly quota' : `${String(percent)}% of this month's ${metric}`} used`;
|
|
1406
|
+
}
|
|
1407
|
+
else if (words?.verb) {
|
|
1408
|
+
sentence = html `<strong>${whoOf(change.actor)}</strong> ${words.verb}${object ? html ` <strong>${object}</strong>` : html ``}${words.after ?? ''}`;
|
|
1409
|
+
}
|
|
1410
|
+
else if (words?.passive) {
|
|
1411
|
+
const [before, rest] = words.passive.split('{X}');
|
|
1412
|
+
sentence = rest === undefined ? html `${before}` : html `${before}<strong>${object ?? 'a resource'}</strong>${rest}`;
|
|
1413
|
+
}
|
|
1414
|
+
else {
|
|
1415
|
+
// A change type this dashboard does not know yet: its label, or its name in words.
|
|
1416
|
+
const label = CHANGE_LABELS[change.type]?.text ?? change.type.replace(/[._]/g, ' ').replace(/^./, (first) => first.toUpperCase());
|
|
1417
|
+
sentence = html `${label}${object ? html ` <span class="on">on</span> <strong>${object}</strong>` : html ``}`;
|
|
1418
|
+
}
|
|
1419
|
+
return html `<li class="event"><span class="dot ${tone}" aria-hidden="true"></span><span class="what"><span class="title">${sentence}${times > 1 ? html ` <span class="times">${times === 2 ? 'twice' : `${String(times)} times`}</span>` : html ``}</span></span><time class="when" datetime="${change.occurredAt.toISOString()}" title="${formatDate(change.occurredAt)}">${ago(change.occurredAt, now())}</time></li>`;
|
|
1305
1420
|
})}</ol>`;
|
|
1306
1421
|
}
|
|
1422
|
+
/** The MCP and A2A endpoints with Copy, and the command that adds Slotlock to an MCP client. */
|
|
1423
|
+
const connectAgent = () => html `${endpoints()}<details class="command"><summary>Claude Code command</summary><div class="copy-block"><pre><code id="mcp-command">claude mcp add --transport http slotlock ${mcpUrl} --header "Authorization: Bearer YOUR_KEY"</code></pre><button class="quiet" type="button" data-copy="mcp-command">Copy</button></div></details>`;
|
|
1307
1424
|
async function overviewPage(session, status = 200, notice) {
|
|
1308
1425
|
const tenantRef = tenantOf(session);
|
|
1309
1426
|
const start = new Date(now());
|
|
1310
1427
|
const window = { start, end: new Date(start.getTime() + AGENDA_DAYS * DAY_MS) };
|
|
1311
|
-
const
|
|
1428
|
+
const assistant = assistantStore();
|
|
1429
|
+
const [keys, resources, agenda, changes, approvals, waitlist, model] = await Promise.all([
|
|
1312
1430
|
options.keys.list({ tenantRef }),
|
|
1313
1431
|
options.resources.list(tenantRef),
|
|
1314
1432
|
options.resources.agenda?.(tenantRef, window, AGENDA_LIMIT),
|
|
1315
1433
|
options.resources.recentChanges?.(tenantRef, ACTIVITY_LIMIT),
|
|
1434
|
+
options.resources.approvals?.list(tenantRef, APPROVALS_SHOWN),
|
|
1435
|
+
options.resources.waitlist?.list(tenantRef, WAITLIST_SHOWN),
|
|
1436
|
+
assistant?.getSettings(tenantRef),
|
|
1316
1437
|
]);
|
|
1438
|
+
// The plan is Live's: the test environment has none of its own.
|
|
1439
|
+
const standing = plans && session.env !== 'test' ? await planStanding(session, 'overview', keys) : null;
|
|
1317
1440
|
const byId = new Map(resources.map((resource) => [resource.id, resource]));
|
|
1318
1441
|
const active = keys.filter((key) => !key.revokedAt && (key.expiresAt === null || key.expiresAt.getTime() > now()));
|
|
1319
1442
|
const counted = (count) => agenda && agenda.length >= AGENDA_LIMIT ? `${count}+` : String(count);
|
|
1320
1443
|
const booked = agenda?.filter((entry) => entry.kind !== 'hold').length ?? 0;
|
|
1321
1444
|
const held = agenda?.filter((entry) => entry.kind === 'hold').length ?? 0;
|
|
1322
|
-
const
|
|
1445
|
+
const waiting = approvals?.filter((approval) => approval.status === 'pending').length ?? 0;
|
|
1446
|
+
const attention = [
|
|
1447
|
+
...(standing?.attention ?? []),
|
|
1448
|
+
...(waiting > 0
|
|
1449
|
+
? [
|
|
1450
|
+
attentionItem('info', `${String(waiting)} ${waiting === 1 ? 'booking waits' : 'bookings wait'} for your approval.`, {
|
|
1451
|
+
href: `${root}/approvals`,
|
|
1452
|
+
label: 'Review',
|
|
1453
|
+
}),
|
|
1454
|
+
]
|
|
1455
|
+
: []),
|
|
1456
|
+
];
|
|
1457
|
+
const admin = isAdmin(session);
|
|
1323
1458
|
const steps = [
|
|
1324
|
-
{ done: resources.length > 0,
|
|
1325
|
-
{ done: active.length > 0,
|
|
1326
|
-
|
|
1459
|
+
{ done: resources.length > 0, title: 'Add a resource', action: { href: `${root}/resources`, label: 'Add resource' } },
|
|
1460
|
+
{ done: active.length > 0, title: 'Create an API key', action: { href: `${root}/keys#new-key`, label: 'Create key' } },
|
|
1461
|
+
// Done once an agent has used a key.
|
|
1462
|
+
{ done: active.some((key) => key.lastUsedAt !== null), title: 'Connect an agent', action: { href: `${root}#connect`, label: 'Connect' } },
|
|
1463
|
+
...(assistant
|
|
1464
|
+
? [
|
|
1465
|
+
admin
|
|
1466
|
+
? { done: Boolean(model), title: 'Connect a model to the assistant', action: { href: `${root}/settings`, label: 'Connect' } }
|
|
1467
|
+
: { done: Boolean(model), title: 'Try the assistant', action: { href: `${root}/assistant`, label: 'Open' } },
|
|
1468
|
+
]
|
|
1469
|
+
: []),
|
|
1327
1470
|
];
|
|
1328
|
-
const
|
|
1329
|
-
|
|
1330
|
-
|
|
1471
|
+
const done = steps.filter((step) => step.done).length;
|
|
1472
|
+
const next = steps.findIndex((step) => !step.done);
|
|
1473
|
+
// Nothing made and nothing happened yet: only the way in.
|
|
1474
|
+
const fresh = resources.length === 0 && active.length === 0 && (changes?.length ?? 0) === 0;
|
|
1475
|
+
const setupCard = next === -1
|
|
1331
1476
|
? html ``
|
|
1332
|
-
: html `<section class="panel setup"><h2>Get started</h2><ol class="steps">${steps.map((step) => html `<li class="${step.done ? 'done' : ''}"><span class="check" aria-hidden="true">${step.done ? ICONS.check : html ``}</span>${step.done ? html `<span
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
${
|
|
1337
|
-
|
|
1338
|
-
|
|
1477
|
+
: html `<section class="panel setup-card"><div class="panel-head"><h2>Get started</h2><span class="meta">${String(done)} of ${String(steps.length)}</span></div><div class="segments" aria-hidden="true">${steps.map((step) => html `<span${step.done ? html ` class="done"` : html ``}></span>`)}</div><ol class="setup-steps">${steps.map((step, index) => html `<li class="${step.done ? 'done' : index === next ? 'next' : ''}"><span class="check" aria-hidden="true">${step.done ? ICONS.check : html ``}</span><span class="step-title">${step.title}${step.done ? html `<span class="visually-hidden"> (done)</span>` : html ``}</span>${index === next ? html `<a class="button primary" href="${step.action.href}">${step.action.label}</a>` : html ``}</li>`)}</ol></section>`;
|
|
1478
|
+
// Mostly set up: one line that leads to the next step.
|
|
1479
|
+
const setupRow = next === -1
|
|
1480
|
+
? html ``
|
|
1481
|
+
: html `<a class="setup-row" href="${steps[next]?.action.href ?? root}"><span class="segments" aria-hidden="true">${steps.map((step) => html `<span${step.done ? html ` class="done"` : html ``}></span>`)}</span><span>Get started · ${String(done)} of ${String(steps.length)} · <strong>${steps[next]?.title ?? ''}</strong></span></a>`;
|
|
1482
|
+
const stat = (label, value, href, tone = '') => html `<a class="stat${tone}" href="${href}"><span class="stat-label">${label}</span><span class="stat-value">${value}</span></a>`;
|
|
1483
|
+
const tiles = [
|
|
1484
|
+
...(agenda ? [stat(`Bookings, next ${AGENDA_DAYS} days`, counted(booked), `${root}#upcoming`), stat('Live holds', counted(held), `${root}#upcoming`)] : []),
|
|
1485
|
+
...(approvals ? [stat('Waiting for approval', String(waiting), `${root}/approvals`, waiting > 0 ? ' attention-stat' : '')] : []),
|
|
1486
|
+
...(waitlist ? [stat('On the waitlist', String(waitlist.filter((entry) => entry.status !== 'expired').length), `${root}/waitlist`)] : []),
|
|
1487
|
+
stat('Resources', String(resources.length), `${root}/resources`),
|
|
1488
|
+
stat('Active keys', String(active.length), `${root}/keys`),
|
|
1489
|
+
].slice(0, 4);
|
|
1490
|
+
const planCard = standing
|
|
1491
|
+
? html `<section class="panel plan-card"><div class="panel-head"><h2>${standing.name}</h2><a href="${root}/plan">Usage</a></div><div class="meters stacked">${standing.meters}</div>${standing.canUpgrade && standing.pressing
|
|
1492
|
+
? html `<a class="button primary" href="${root}/plan#upgrade">Upgrade</a>`
|
|
1493
|
+
: html ``}</section>`
|
|
1494
|
+
: html ``;
|
|
1495
|
+
const connectCard = html `<section class="panel" id="connect"><h2>Connect an agent</h2>${connectAgent()}</section>`;
|
|
1496
|
+
const body = fresh
|
|
1497
|
+
? standing
|
|
1498
|
+
? html `<div class="split">${setupCard}<div class="column">${planCard}</div></div>`
|
|
1499
|
+
: setupCard
|
|
1500
|
+
: html `${done * 2 >= steps.length ? setupRow : setupCard}
|
|
1501
|
+
<div class="stats">${tiles}</div>
|
|
1339
1502
|
${session.env === 'test' && options.resources.sandbox ? sandboxPanel(session, resources.length) : html ``}
|
|
1340
1503
|
<div class="split">
|
|
1504
|
+
<div class="column">
|
|
1341
1505
|
${agenda ? html `<section class="panel" id="upcoming"><h2>Upcoming</h2>${agendaPanel(agenda, byId, agenda.length > AGENDA_SHOWN)}</section>` : html ``}
|
|
1342
|
-
${changes ? html `<section class="panel"><h2>
|
|
1506
|
+
${changes ? html `<section class="panel"><h2>Activity</h2>${activityPanel(changes, byId, keys, session)}</section>` : html ``}
|
|
1507
|
+
</div>
|
|
1508
|
+
<div class="column">${planCard}${connectCard}</div>
|
|
1343
1509
|
</div>`;
|
|
1510
|
+
const content = html `${noticeOf(notice, status)}
|
|
1511
|
+
${pageHead('Overview')}
|
|
1512
|
+
${attentionList(attention)}
|
|
1513
|
+
${fresh && session.env === 'test' && options.resources.sandbox ? sandboxPanel(session, resources.length) : html ``}
|
|
1514
|
+
${body}`;
|
|
1344
1515
|
return respond(status, layout('Overview', content, session, 'overview'));
|
|
1345
1516
|
}
|
|
1346
1517
|
/** In Test: load a scenario to try an agent against, move waiting forward, or start again (RFC 0004 A6). */
|
|
@@ -1404,7 +1575,7 @@ ${resourceCount === 0
|
|
|
1404
1575
|
const limitChoices = (locations?.length ?? 0) + (groups?.length ?? 0) > 0
|
|
1405
1576
|
? html `<details class="types"><summary>Limit to locations or groups (optional)</summary><div class="checks">${(locations ?? []).map((location) => html `<label class="choice"><input type="checkbox" name="limit_location" value="${location.ref}"> ${paths.get(location.ref) ?? location.name}</label>`)}${(groups ?? []).map((group) => html `<label class="choice"><input type="checkbox" name="limit_group" value="${group.ref}"> Group: ${group.name}</label>`)}</div><p class="fine">The key then reaches only the resources there (a location with every location in it), read and write only. Developer plan and up.</p></details>`
|
|
1406
1577
|
: html ``;
|
|
1407
|
-
const content = html `${noticeOf(notice)}
|
|
1578
|
+
const content = html `${noticeOf(notice, status)}
|
|
1408
1579
|
${pageHead('API keys')}
|
|
1409
1580
|
<section class="panel">
|
|
1410
1581
|
<h2>Connect an agent</h2>
|
|
@@ -1435,7 +1606,7 @@ ${keys.length === 0
|
|
|
1435
1606
|
* dashboard's own limit, or its plan's when lower (archived resources count against a plan).
|
|
1436
1607
|
*/
|
|
1437
1608
|
async function resourceAllowance(tenantRef, resources) {
|
|
1438
|
-
const plan =
|
|
1609
|
+
const plan = plans ? await plans.plan(tenantRef) : null;
|
|
1439
1610
|
if (!plan || plan.limits.resources >= SLOTLOCK_DASHBOARD_MAX_RESOURCES) {
|
|
1440
1611
|
return { used: resources.length, limit: SLOTLOCK_DASHBOARD_MAX_RESOURCES, planned: false, archived: 0 };
|
|
1441
1612
|
}
|
|
@@ -1451,7 +1622,7 @@ ${keys.length === 0
|
|
|
1451
1622
|
const resources = await options.resources.list(tenantOf(session));
|
|
1452
1623
|
const allowance = await resourceAllowance(tenantOf(session), resources);
|
|
1453
1624
|
const token = csrfToken(session);
|
|
1454
|
-
const content = html `${noticeOf(notice)}
|
|
1625
|
+
const content = html `${noticeOf(notice, status)}
|
|
1455
1626
|
${pageHead('Resources', html `<div class="row-actions">${options.resources.grouping ? html `<a class="button" href="${root}/locations">Locations and groups</a>` : html ``}${resources.length > 0 ? html `<a class="button" href="${root}/hours">Set hours for every resource</a>` : html ``}</div>`)}
|
|
1456
1627
|
<section class="panel" id="add-resource">
|
|
1457
1628
|
<h2>Add a resource</h2>
|
|
@@ -1510,7 +1681,7 @@ ${resources.length === 0
|
|
|
1510
1681
|
<button type="submit">Save group</button></form></div></details><details class="act danger"><summary>Delete</summary><div class="pop"><p>Its resources stay as they are, in no group of this name.</p>
|
|
1511
1682
|
<form method="post" action="${root}/groups/delete">${sent()}<input type="hidden" name="ref" value="${group.ref}"><button class="danger" type="submit">Delete group</button></form></div></details></td>
|
|
1512
1683
|
</tr>`);
|
|
1513
|
-
const content = html `${noticeOf(notice)}
|
|
1684
|
+
const content = html `${noticeOf(notice, status)}
|
|
1514
1685
|
<p><a class="back" href="${root}/resources">Resources</a></p>
|
|
1515
1686
|
${pageHead('Locations and groups')}
|
|
1516
1687
|
<section class="panel" id="locations">
|
|
@@ -1542,32 +1713,44 @@ ${groups.length === 0
|
|
|
1542
1713
|
</section>`;
|
|
1543
1714
|
return respond(status, layout('Locations and groups', content, session, 'resources'));
|
|
1544
1715
|
}
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
${
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
<
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1716
|
+
/** Settings' sections, each its own page: the assistant's form apart from what is only read. */
|
|
1717
|
+
const SETTINGS_SECTIONS = Object.freeze([
|
|
1718
|
+
{ id: 'assistant', label: 'Assistant', path: '/settings' },
|
|
1719
|
+
{ id: 'endpoints', label: 'Endpoints', path: '/settings/endpoints' },
|
|
1720
|
+
{ id: 'hours', label: 'Hours', path: '/settings/hours' },
|
|
1721
|
+
{ id: 'account', label: 'Account', path: '/settings/account' },
|
|
1722
|
+
]);
|
|
1723
|
+
async function settingsPage(session, status = 200, notice, draft, section = 'assistant') {
|
|
1724
|
+
const store = assistantStore();
|
|
1725
|
+
const sections = SETTINGS_SECTIONS.filter((entry) => entry.id !== 'assistant' || store);
|
|
1726
|
+
const shown = section === 'assistant' && !store ? 'endpoints' : section;
|
|
1727
|
+
const connected = store ? Boolean(await store.getSettings(tenantOf(session))) : false;
|
|
1728
|
+
const nav = html `<nav class="subnav" aria-label="Settings">${sections.map((entry) => html `<a href="${root}${entry.path}"${entry.id === shown ? html ` aria-current="page"` : html ``}>${entry.label}${entry.id === 'assistant' && connected ? html `<span class="nav-dot" aria-hidden="true"></span>` : html ``}</a>`)}</nav>`;
|
|
1729
|
+
let body;
|
|
1730
|
+
if (shown === 'assistant') {
|
|
1731
|
+
body = await assistantSettingsSection(session, status, notice, draft);
|
|
1732
|
+
}
|
|
1733
|
+
else if (shown === 'endpoints') {
|
|
1734
|
+
body = html `${noticeOf(notice, status)}<div class="set-card"><div class="row"><span class="row-label">MCP</span><div class="row-value">${copyField('mcp-url', mcpUrl)}</div></div><div class="row"><span class="row-label">A2A</span><div class="row-value">${copyField('a2a-url', a2aUrl)}</div></div></div>`;
|
|
1735
|
+
}
|
|
1736
|
+
else if (shown === 'hours') {
|
|
1737
|
+
const rules = options.availability ?? [];
|
|
1738
|
+
body = html `${noticeOf(notice, status)}<div class="set-card"><div class="row"><span class="row-label">Default hours</span><div class="row-value${rules.length === 0 ? ' muted' : ''}">${rules.length === 0
|
|
1739
|
+
? 'None. Agents find free time only on resources with hours of their own.'
|
|
1740
|
+
: html `${rules.map((rule, index) => html `${index > 0 ? html `<br>` : html ``}${daysOf(rule.rrule)}: ${hoursOf(rule.startMinutes, rule.durationMinutes)}`)}`}</div></div></div><p class="fine">In each resource's own time zone, for resources without hours of their own. Set by the server's operator.</p>`;
|
|
1741
|
+
}
|
|
1742
|
+
else {
|
|
1743
|
+
const current = session.orgs?.find((org) => org.id === session.org);
|
|
1744
|
+
const others = (session.orgs ?? []).filter((org) => org.id !== current?.id);
|
|
1745
|
+
body = html `${noticeOf(notice, status)}<div class="set-card"><div class="row"><span class="row-label">Signed in as</span><div class="row-value">${shownAs(session)}</div></div><div class="row"><span class="row-label">${current ? 'Organization' : 'Workspace'}</span><div class="row-value">${current ? current.login : 'Personal'}</div></div>${current && others.length > 0
|
|
1746
|
+
? html `<div class="row"><span class="row-label">Also a member of</span><div class="row-value">${others.map((org) => org.login).join(', ')}</div></div>`
|
|
1747
|
+
: html ``}<div class="row"><span class="row-label">Session ends</span><div class="row-value">${formatDate(new Date(session.exp))}</div></div></div>${current ? html `<p class="fine">Membership is read from GitHub when you sign in.</p>` : html ``}`;
|
|
1748
|
+
}
|
|
1749
|
+
const content = html `${pageHead('Settings')}<div class="settings">${nav}<div class="settings-body">${body}</div></div>`;
|
|
1569
1750
|
return respond(status, layout('Settings', content, session, 'settings'));
|
|
1570
1751
|
}
|
|
1752
|
+
/** A value to copy: monospace, cut with an ellipsis, with a Copy button (app.js). */
|
|
1753
|
+
const copyField = (id, value) => html `<dl class="endpoints"><dd><code id="${id}">${value}</code><button class="quiet" type="button" data-copy="${id}">Copy</button></dd></dl>`;
|
|
1571
1754
|
/** The tenant's resource with that id and every resource (for names), or null for anything else. */
|
|
1572
1755
|
async function resourceDetailPage(session, id, status = 200, notice) {
|
|
1573
1756
|
const tenantRef = tenantOf(session);
|
|
@@ -1612,7 +1795,7 @@ ${current ? html `<p class="fine">Membership is read from GitHub when you sign i
|
|
|
1612
1795
|
const fallback = from ? `As its default (${from.value ? 'on' : 'off'}, ${from.from})` : 'Off';
|
|
1613
1796
|
return html `<label>${label}<select name="${key}"><option value=""${chosen(undefined)}>${fallback}</option><option value="1"${chosen(true)}>On</option><option value="0"${chosen(false)}>Off</option></select></label>`;
|
|
1614
1797
|
};
|
|
1615
|
-
const content = html `${noticeOf(notice)}
|
|
1798
|
+
const content = html `${noticeOf(notice, status)}
|
|
1616
1799
|
<p><a class="back" href="${root}/resources">Resources</a></p>
|
|
1617
1800
|
${pageHead(resourceName(resource, resource.id))}
|
|
1618
1801
|
${resource.description ? html `<p>${resource.description}</p>` : html ``}
|
|
@@ -1960,7 +2143,7 @@ ${activity ? html `<section class="panel"><h2>Activity</h2>${activityPanel(activ
|
|
|
1960
2143
|
<form method="post" action="${root}/webhooks/delete"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="id" value="${endpoint.id}"><button class="danger" type="submit">Delete webhook</button></form></div></details></td>
|
|
1961
2144
|
</tr>`;
|
|
1962
2145
|
});
|
|
1963
|
-
const content = html `${noticeOf(notice)}
|
|
2146
|
+
const content = html `${noticeOf(notice, status)}
|
|
1964
2147
|
${pageHead('Webhooks')}
|
|
1965
2148
|
<div class="stats">
|
|
1966
2149
|
<div class="stat"><span class="stat-label">Endpoints</span><span class="stat-value">${String(endpoints.length)}</span></div>
|
|
@@ -1991,7 +2174,7 @@ ${endpoints.length === 0
|
|
|
1991
2174
|
const created = await hooks.create(tenantOf(session), (form.get('url') ?? '').trim(), types.length === 0 ? undefined : types, personOf(session));
|
|
1992
2175
|
if (!created.ok) {
|
|
1993
2176
|
const message = created.code === 'webhook_limit_reached'
|
|
1994
|
-
? `You have ${String(created.limit)} webhooks, as many as ${
|
|
2177
|
+
? `You have ${String(created.limit)} webhooks, as many as ${plans ? 'your plan' : 'Slotlock'} allows. Delete one first${plans ? ', or see Usage' : ''}.`
|
|
1995
2178
|
: created.code === 'invalid_webhook_url'
|
|
1996
2179
|
? 'Give an https URL, with no user name, password or #fragment.'
|
|
1997
2180
|
: 'Choose events from the list.';
|
|
@@ -2060,7 +2243,7 @@ ${endpoints.length === 0
|
|
|
2060
2243
|
});
|
|
2061
2244
|
const waiting = entries.filter((entry) => entry.status === 'waiting').length;
|
|
2062
2245
|
const offered = entries.filter((entry) => entry.status === 'offered').length;
|
|
2063
|
-
const content = html `${noticeOf(notice)}
|
|
2246
|
+
const content = html `${noticeOf(notice, status)}
|
|
2064
2247
|
${pageHead('Waitlist')}
|
|
2065
2248
|
<div class="stats">
|
|
2066
2249
|
<div class="stat"><span class="stat-label">Waiting</span><span class="stat-value">${String(waiting)}</span></div>
|
|
@@ -2126,7 +2309,7 @@ ${entries.length === 0
|
|
|
2126
2309
|
<td data-label="Decided by"><code class="truncate" title="${entry.decidedBy ?? ''}">${entry.decidedBy ?? '—'}</code></td>
|
|
2127
2310
|
<td data-label="Asked">${ago(entry.createdAt, now())}</td>
|
|
2128
2311
|
</tr>`);
|
|
2129
|
-
const content = html `${noticeOf(notice)}
|
|
2312
|
+
const content = html `${noticeOf(notice, status)}
|
|
2130
2313
|
${pageHead('Approvals')}
|
|
2131
2314
|
<p class="fine">Bookings an approval policy holds for a person. Their time stays held until you decide or the wait ends. Policies are set through the API (slotlock_put_approval_policy).</p>
|
|
2132
2315
|
<div class="stats">
|
|
@@ -2367,38 +2550,126 @@ ${decided.length === 0
|
|
|
2367
2550
|
model: { provider: settings.provider, baseUrl: settings.baseUrl, model: settings.model, apiKey },
|
|
2368
2551
|
};
|
|
2369
2552
|
}
|
|
2553
|
+
/** When a change happens, in its resource's time zone when the card knows it, else UTC. */
|
|
2554
|
+
function whenOf(start, end, zone) {
|
|
2555
|
+
if (typeof start !== 'string')
|
|
2556
|
+
return null;
|
|
2557
|
+
const from = new Date(start);
|
|
2558
|
+
if (Number.isNaN(from.getTime()))
|
|
2559
|
+
return null;
|
|
2560
|
+
const timeZone = zone ?? 'UTC';
|
|
2561
|
+
let day;
|
|
2562
|
+
let time;
|
|
2563
|
+
try {
|
|
2564
|
+
day = new Intl.DateTimeFormat('en-GB', { weekday: 'short', day: 'numeric', month: 'short', timeZone });
|
|
2565
|
+
time = new Intl.DateTimeFormat('en-GB', { hour: '2-digit', minute: '2-digit', hourCycle: 'h23', timeZone });
|
|
2566
|
+
}
|
|
2567
|
+
catch {
|
|
2568
|
+
return null;
|
|
2569
|
+
}
|
|
2570
|
+
const to = typeof end === 'string' ? new Date(end) : null;
|
|
2571
|
+
const span = to && !Number.isNaN(to.getTime())
|
|
2572
|
+
? day.format(from) === day.format(to)
|
|
2573
|
+
? `${time.format(from)}–${time.format(to)}`
|
|
2574
|
+
: `${time.format(from)} – ${day.format(to)}, ${time.format(to)}`
|
|
2575
|
+
: time.format(from);
|
|
2576
|
+
return `${day.format(from)}, ${span} ${timeZone}`;
|
|
2577
|
+
}
|
|
2578
|
+
/** What a proposed change's fields are called on its card. */
|
|
2579
|
+
const FACT_NAMES = Object.freeze({
|
|
2580
|
+
resource_id: 'Resource',
|
|
2581
|
+
resource_ids: 'Resources',
|
|
2582
|
+
pool_id: 'Pool',
|
|
2583
|
+
title: 'Title',
|
|
2584
|
+
external_ref: 'Reference',
|
|
2585
|
+
event_id: 'Booking',
|
|
2586
|
+
hold_id: 'Hold',
|
|
2587
|
+
ttl_seconds: 'Held for',
|
|
2588
|
+
notes: 'Note',
|
|
2589
|
+
description: 'Note',
|
|
2590
|
+
metadata: 'Details',
|
|
2591
|
+
slots: 'Times',
|
|
2592
|
+
});
|
|
2370
2593
|
/**
|
|
2371
|
-
* A field of a proposed change, as the card shows it: all of it, wrapped, never cut (the
|
|
2372
|
-
*
|
|
2594
|
+
* A field of a proposed change, as the card shows it: all of it, wrapped, never cut (the proposal
|
|
2595
|
+
* is bounded instead). A resource is named; its id is the name's title.
|
|
2373
2596
|
*/
|
|
2374
2597
|
function fieldValue(name, value, names) {
|
|
2375
|
-
const named = (id) =>
|
|
2376
|
-
if (typeof value === 'string')
|
|
2377
|
-
return
|
|
2378
|
-
|
|
2598
|
+
const named = (id) => names.has(id) ? html `<span title="${id}">${names.get(id)}</span>` : html `<code>${id}</code>`;
|
|
2599
|
+
if (name === 'resource_id' && typeof value === 'string')
|
|
2600
|
+
return named(value);
|
|
2601
|
+
if (name === 'resource_ids' && Array.isArray(value) && value.every((item) => typeof item === 'string')) {
|
|
2602
|
+
return html `${value.map((id, index) => html `${index > 0 ? ', ' : ''}${named(id)}`)}`;
|
|
2603
|
+
}
|
|
2604
|
+
if (name === 'ttl_seconds' && typeof value === 'number') {
|
|
2605
|
+
return html `${value >= 60 ? `${Math.round(value / 60)} min` : `${value} s`}`;
|
|
2606
|
+
}
|
|
2607
|
+
if ((name === 'event_id' || name === 'hold_id') && typeof value === 'string')
|
|
2608
|
+
return html `<code>${value}</code>`;
|
|
2609
|
+
if (typeof value === 'string')
|
|
2610
|
+
return html `<span class="wrap">${value}</span>`;
|
|
2379
2611
|
if (Array.isArray(value) && value.every((item) => typeof item === 'string')) {
|
|
2380
|
-
return html `<span class="wrap">${value.
|
|
2612
|
+
return html `<span class="wrap">${value.join(', ')}</span>`;
|
|
2381
2613
|
}
|
|
2382
|
-
if (typeof value === 'number' || typeof value === 'boolean' || value === null)
|
|
2614
|
+
if (typeof value === 'number' || typeof value === 'boolean' || value === null)
|
|
2383
2615
|
return html `${String(value)}`;
|
|
2384
|
-
}
|
|
2385
|
-
return html `<pre class="wrap">${JSON.stringify(value, null, 2)}</pre>`;
|
|
2616
|
+
return html `<pre>${JSON.stringify(value, null, 2)}</pre>`;
|
|
2386
2617
|
}
|
|
2387
|
-
|
|
2618
|
+
/** Why a change or a step was refused, in words, for the codes a member meets. */
|
|
2619
|
+
const REFUSALS = Object.freeze({
|
|
2620
|
+
overlap: 'that time was just taken',
|
|
2621
|
+
pool_full: 'the pool is full then',
|
|
2622
|
+
not_found: 'it no longer exists',
|
|
2623
|
+
resource_not_found: 'the resource no longer exists',
|
|
2624
|
+
event_not_found: 'the booking no longer exists',
|
|
2625
|
+
invalid_input: 'the request was incomplete',
|
|
2626
|
+
invalid_arguments: 'the request was incomplete',
|
|
2627
|
+
validation_error: 'the request was incomplete',
|
|
2628
|
+
turn_read_budget_exceeded: 'one message may read only so much',
|
|
2629
|
+
too_many_pending_changes: 'too many changes are waiting for you',
|
|
2630
|
+
change_too_large_to_show: 'the change was too large to show',
|
|
2631
|
+
tool_not_available: 'the assistant may not do that',
|
|
2632
|
+
too_many_calls: 'too many at once',
|
|
2633
|
+
quota_exceeded: "this month's allowance is used up",
|
|
2634
|
+
approval_pending: 'it is waiting for an approval',
|
|
2635
|
+
});
|
|
2636
|
+
const refusal = (code) => REFUSALS[code] ?? code.replaceAll('_', ' ');
|
|
2637
|
+
function actionCard(session, action, names, zones) {
|
|
2638
|
+
const verb = slotlockAssistantActionVerb(action.operation);
|
|
2639
|
+
const title = action.summary || `${verb.verb}: ${toolTitles.get(action.operation) ?? action.operation}`;
|
|
2640
|
+
if (action.status !== 'pending') {
|
|
2641
|
+
const [tone, word] = action.status === 'done'
|
|
2642
|
+
? ['ok', verb.done]
|
|
2643
|
+
: action.status === 'dismissed'
|
|
2644
|
+
? ['', 'Declined']
|
|
2645
|
+
: action.status === 'approved'
|
|
2646
|
+
? ['', 'Running…']
|
|
2647
|
+
: ['bad', `Refused: ${refusal(action.outcome ?? 'failed')}`];
|
|
2648
|
+
return html `<li class="turn action" id="action-${action.id}"><p class="receipt ${tone}"><span>${title}</span><span class="why">${word}</span></p></li>`;
|
|
2649
|
+
}
|
|
2388
2650
|
const token = csrfToken(session);
|
|
2389
|
-
const
|
|
2390
|
-
|
|
2391
|
-
const
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
const
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2651
|
+
const input = Object.entries(action.input).filter(([name]) => name !== 'idempotency_key');
|
|
2652
|
+
// The change's own time zone when it gives one, else its resource's.
|
|
2653
|
+
const zone = typeof action.input.timezone === 'string'
|
|
2654
|
+
? action.input.timezone
|
|
2655
|
+
: typeof action.input.resource_id === 'string'
|
|
2656
|
+
? zones.get(action.input.resource_id)
|
|
2657
|
+
: undefined;
|
|
2658
|
+
const when = whenOf(action.input.starts_at, action.input.ends_at, zone);
|
|
2659
|
+
const facts = [];
|
|
2660
|
+
const more = [];
|
|
2661
|
+
if (when)
|
|
2662
|
+
facts.push(html `<dt>When</dt><dd>${when}</dd>`);
|
|
2663
|
+
for (const [name, value] of input) {
|
|
2664
|
+
if (when && (name === 'starts_at' || name === 'ends_at' || name === 'timezone'))
|
|
2665
|
+
continue;
|
|
2666
|
+
const label = FACT_NAMES[name] ?? name.charAt(0).toUpperCase() + name.slice(1).replaceAll('_', ' ');
|
|
2667
|
+
(name in FACT_NAMES || name === 'attendees' ? facts : more).push(html `<dt>${label}</dt><dd>${fieldValue(name, value, names)}</dd>`);
|
|
2668
|
+
}
|
|
2669
|
+
const hidden = (id) => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="action" value="${id}">`;
|
|
2670
|
+
return html `<li class="turn action" id="action-${action.id}"><div class="change"><p class="change-title">${title}</p>${facts.length > 0 ? html `<dl class="facts">${facts}</dl>` : html ``}${more.length > 0
|
|
2671
|
+
? html `<details class="more"><summary>All details</summary><dl class="facts">${more}</dl></details>`
|
|
2672
|
+
: html ``}<div class="change-buttons"><form method="post" action="${root}/assistant/approve">${hidden(action.id)}<button class="${verb.danger ? 'danger' : 'primary'}" type="submit" data-busy="${verb.busy}">${verb.verb}</button></form><form method="post" action="${root}/assistant/dismiss">${hidden(action.id)}<button class="ghost" type="submit">Decline</button></form></div></div></li>`;
|
|
2402
2673
|
}
|
|
2403
2674
|
function readAction(id, value) {
|
|
2404
2675
|
if (!isRecord(value) || typeof value.operation !== 'string')
|
|
@@ -2412,6 +2683,100 @@ ${decided.length === 0
|
|
|
2412
2683
|
...(typeof value.outcome === 'string' ? { outcome: value.outcome } : {}),
|
|
2413
2684
|
};
|
|
2414
2685
|
}
|
|
2686
|
+
/** The code a tool result was refused with, from the JSON the model was given. */
|
|
2687
|
+
function refusedCode(content) {
|
|
2688
|
+
try {
|
|
2689
|
+
const parsed = JSON.parse(content);
|
|
2690
|
+
const error = isRecord(parsed) && isRecord(parsed.error) ? parsed.error : null;
|
|
2691
|
+
return error && typeof error.code === 'string' ? error.code : null;
|
|
2692
|
+
}
|
|
2693
|
+
catch {
|
|
2694
|
+
return null;
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
/** Notes the turn ends with that are not a failure: a member's Stop, and the step bound. */
|
|
2698
|
+
const quietNote = (text) => text === 'Stopped.' || text.startsWith('The assistant stopped after');
|
|
2699
|
+
/**
|
|
2700
|
+
* The conversation as the thread shows it: the member's messages as bubbles, the replies as
|
|
2701
|
+
* rendered Markdown, each run of tool steps folded into one line, proposed changes as cards, and
|
|
2702
|
+
* a failure that ends it with a way to send the last message again.
|
|
2703
|
+
*/
|
|
2704
|
+
function assistantItems(session, entries, actions, names, zones) {
|
|
2705
|
+
const items = [];
|
|
2706
|
+
let steps = [];
|
|
2707
|
+
let cards = [];
|
|
2708
|
+
const flush = () => {
|
|
2709
|
+
if (steps.length > 0) {
|
|
2710
|
+
const bad = steps.some((step) => !step.ok);
|
|
2711
|
+
const seen = [];
|
|
2712
|
+
for (const step of steps)
|
|
2713
|
+
if (!seen.includes(step.label))
|
|
2714
|
+
seen.push(step.label);
|
|
2715
|
+
const summary = seen.map((label, index) => (index === 0 ? label : label.charAt(0).toLowerCase() + label.slice(1))).join(', ');
|
|
2716
|
+
items.push(html `<li class="turn steps"><details class="steps${bad ? ' has-bad' : ''}"><summary>${summary}</summary><ul class="step-list">${steps.map((step) => html `<li class="step ${step.ok ? 'ok' : 'bad'}">${step.label}${step.why ? html ` <span class="why">${step.why}</span>` : html ``}</li>`)}</ul></details></li>`);
|
|
2717
|
+
steps = [];
|
|
2718
|
+
}
|
|
2719
|
+
items.push(...cards);
|
|
2720
|
+
cards = [];
|
|
2721
|
+
};
|
|
2722
|
+
const lastUser = [...entries].reverse().find((entry) => entry.role === 'user');
|
|
2723
|
+
for (const [index, entry] of entries.entries()) {
|
|
2724
|
+
if (entry.role === 'user') {
|
|
2725
|
+
flush();
|
|
2726
|
+
items.push(html `<li class="turn mine"><div class="bubble">${entry.text}</div></li>`);
|
|
2727
|
+
}
|
|
2728
|
+
else if (entry.role === 'assistant') {
|
|
2729
|
+
if (entry.text.trim() === '')
|
|
2730
|
+
continue;
|
|
2731
|
+
flush();
|
|
2732
|
+
items.push(html `<li class="turn bot"><div class="prose">${new Html(renderSlotlockMarkdown(entry.text))}</div></li>`);
|
|
2733
|
+
}
|
|
2734
|
+
else if (entry.role === 'tool') {
|
|
2735
|
+
for (const result of entry.results) {
|
|
2736
|
+
const write = SLOTLOCK_ASSISTANT_TOOL_USE[result.name] === 'propose';
|
|
2737
|
+
const action = result.action ? readAction(result.action, actions[result.action]) : null;
|
|
2738
|
+
if (action) {
|
|
2739
|
+
cards.push(actionCard(session, action, names, zones));
|
|
2740
|
+
continue;
|
|
2741
|
+
}
|
|
2742
|
+
const title = toolTitles.get(result.name) ?? result.name;
|
|
2743
|
+
const code = result.error ? refusedCode(result.content) : null;
|
|
2744
|
+
steps.push({
|
|
2745
|
+
label: slotlockAssistantStepLabel(result.name, title, result.error ? 'bad' : 'ok', write),
|
|
2746
|
+
ok: !result.error,
|
|
2747
|
+
why: code ? refusal(code) : null,
|
|
2748
|
+
});
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
else if (entry.role === 'note') {
|
|
2752
|
+
flush();
|
|
2753
|
+
const last = index === entries.length - 1;
|
|
2754
|
+
if (last && !quietNote(entry.text) && lastUser?.role === 'user') {
|
|
2755
|
+
items.push(html `<li class="turn error" role="alert"><span>${entry.text}</span><form method="post" action="${root}/assistant"><input type="hidden" name="csrf" value="${csrfToken(session)}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="message" value="${lastUser.text}"><button class="text-button" type="submit">Retry</button></form></li>`);
|
|
2756
|
+
}
|
|
2757
|
+
else {
|
|
2758
|
+
items.push(html `<li class="turn note">${entry.text}</li>`);
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2761
|
+
}
|
|
2762
|
+
flush();
|
|
2763
|
+
return items;
|
|
2764
|
+
}
|
|
2765
|
+
/** The tenant's resources as a proposed change's card names them, and each one's time zone. */
|
|
2766
|
+
async function resourceLookups(tenantRef) {
|
|
2767
|
+
const resources = await options.resources.list(tenantRef);
|
|
2768
|
+
return {
|
|
2769
|
+
resources,
|
|
2770
|
+
names: new Map(resources.map((resource) => [resource.id, resourceName(resource, resource.id)])),
|
|
2771
|
+
zones: new Map(resources.flatMap((resource) => (resource.timezone ? [[resource.id, resource.timezone]] : []))),
|
|
2772
|
+
};
|
|
2773
|
+
}
|
|
2774
|
+
const ASSISTANT_ICONS = {
|
|
2775
|
+
send: new Html('<svg class="arrow" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 13V3M3.5 7.5 8 3l4.5 4.5"/></svg>'),
|
|
2776
|
+
stop: new Html('<svg class="stop" viewBox="0 0 16 16" aria-hidden="true"><rect x="4" y="4" width="8" height="8" rx="1.5" fill="currentColor"/></svg>'),
|
|
2777
|
+
down: new Html('<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3v10M3.5 8.5 8 13l4.5-4.5"/></svg>'),
|
|
2778
|
+
plus: new Html('<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M8 3v10M3 8h10"/></svg>'),
|
|
2779
|
+
};
|
|
2415
2780
|
async function assistantPage(session, status = 200, notice) {
|
|
2416
2781
|
const store = assistantStore();
|
|
2417
2782
|
if (!store)
|
|
@@ -2421,91 +2786,100 @@ ${decided.length === 0
|
|
|
2421
2786
|
store.getSettings(tenantRef),
|
|
2422
2787
|
store.getConversation(tenantRef, personOf(session)),
|
|
2423
2788
|
]);
|
|
2789
|
+
if (!settings) {
|
|
2790
|
+
const content = html `${noticeOf(notice, status)}${pageHead('Assistant')}${emptyState(ICONS.assistant, isAdmin(session) ? 'Connect a model to use the assistant.' : 'An admin of this organization connects a model in Settings.', isAdmin(session) ? { href: `${root}/settings#assistant`, label: 'Connect a model' } : undefined)}`;
|
|
2791
|
+
return respond(status, layout('Assistant', content, session, 'assistant'));
|
|
2792
|
+
}
|
|
2424
2793
|
const entries = readSlotlockAssistantEntries(conversation?.messages);
|
|
2425
2794
|
const actions = conversation?.actions ?? {};
|
|
2426
|
-
const
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
}
|
|
2431
|
-
}
|
|
2795
|
+
const empty = entries.length === 0;
|
|
2796
|
+
const { resources, names, zones } = empty || Object.keys(actions).length > 0
|
|
2797
|
+
? await resourceLookups(tenantRef)
|
|
2798
|
+
: { resources: [], names: new Map(), zones: new Map() };
|
|
2432
2799
|
const token = csrfToken(session);
|
|
2433
|
-
const
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
}
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
}
|
|
2450
|
-
}
|
|
2451
|
-
}
|
|
2452
|
-
}
|
|
2453
|
-
const composer = html `<form class="composer" method="post" action="${root}/assistant"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><label class="visually-hidden" for="message">Message</label><textarea id="message" name="message" rows="3" maxlength="${String(SLOTLOCK_ASSISTANT_MAX_MESSAGE)}" required placeholder="Find a free van tomorrow morning and book it for Ana"></textarea><button class="primary" type="submit">Send</button></form>`;
|
|
2454
|
-
const reset = entries.length > 0
|
|
2455
|
-
? html `<form class="inline" method="post" action="${root}/assistant/reset"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><button type="submit">New conversation</button></form>`
|
|
2456
|
-
: undefined;
|
|
2457
|
-
const content = html `${noticeOf(notice)}
|
|
2458
|
-
${pageHead('Assistant', reset)}
|
|
2459
|
-
${settings
|
|
2460
|
-
? html `<section class="panel chat">${items.length === 0
|
|
2461
|
-
? html `<p class="empty">Ask about bookings and free time. Changes wait for your approval.</p>`
|
|
2462
|
-
: html `<ol class="thread">${items}</ol>`}<span id="latest"></span>${composer}</section>`
|
|
2463
|
-
: html `<p class="empty">${isAdmin(session)
|
|
2464
|
-
? html `No model is connected. <a href="${root}/settings#assistant">Connect one in Settings</a>.`
|
|
2465
|
-
: 'No model is connected. An admin of this organization can connect one in Settings.'}</p>`}`;
|
|
2800
|
+
const hidden = () => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}">`;
|
|
2801
|
+
const first = resources.find((resource) => !resource.archivedAt);
|
|
2802
|
+
const named = first ? resourceName(first, first.id) : null;
|
|
2803
|
+
// A read chip asks at once; a write chip fills the box to finish (app.js), or asks (no script).
|
|
2804
|
+
const prompts = [
|
|
2805
|
+
{ label: "What's on today", message: "What's booked today?" },
|
|
2806
|
+
{ label: 'Find free time', message: named ? `When is ${named} free tomorrow morning?` : 'What is free tomorrow morning?' },
|
|
2807
|
+
{ label: 'Book', message: named ? `Book ${named} tomorrow 9:00–10:00` : 'Book ', fill: true },
|
|
2808
|
+
{ label: 'Move a booking', message: 'Move the next booking one hour later', fill: true },
|
|
2809
|
+
{ label: 'Cancel a booking', message: 'Cancel the next booking', fill: true },
|
|
2810
|
+
];
|
|
2811
|
+
const chips = html `<div class="assistant-start"><div class="chips">${prompts.map((prompt) => html `<form method="post" action="${root}/assistant">${hidden()}<button class="chip" type="submit" name="message" value="${prompt.message}"${prompt.fill ? html ` data-fill="${prompt.message}"` : html ``}>${prompt.label}</button></form>`)}</div></div>`;
|
|
2812
|
+
const composer = html `<div class="dock"><button class="jump" type="button" hidden aria-label="Jump to the latest message">${ASSISTANT_ICONS.down}</button><form class="composer" method="post" action="${root}/assistant">${hidden()}<label class="visually-hidden" for="message">Message</label><textarea id="message" name="message" rows="2" maxlength="${String(SLOTLOCK_ASSISTANT_MAX_MESSAGE)}" required placeholder="${empty ? 'Ask about bookings' : 'Reply…'}"></textarea><div class="composer-bar"><span class="count" hidden></span><button class="send" type="submit" aria-label="Send">${ASSISTANT_ICONS.send}${ASSISTANT_ICONS.stop}</button></div></form></div>`;
|
|
2813
|
+
const reset = html `<form class="inline new-chat" method="post" action="${root}/assistant/reset">${hidden()}<button class="ghost" type="submit">${ASSISTANT_ICONS.plus}New conversation</button></form>`;
|
|
2814
|
+
const flag = empty ? new Html(' data-empty') : html ``;
|
|
2815
|
+
const content = html `${noticeOf(notice, status)}<div class="assistant-page"${flag}>${pageHead('Assistant', reset)}<div class="assistant" data-assistant data-stream="${root}/assistant/stream" data-page="${root}/assistant"${flag}><ol class="thread">${assistantItems(session, entries, actions, names, zones)}</ol><span id="latest"></span>${chips}${composer}</div></div>`;
|
|
2466
2816
|
return respond(status, layout('Assistant', content, session, 'assistant'));
|
|
2467
2817
|
}
|
|
2468
|
-
|
|
2818
|
+
/** The named provider a provider and base URL are, or none (Custom). */
|
|
2819
|
+
const presetOf = (provider, baseUrl) => SLOTLOCK_ASSISTANT_PRESETS.find((preset) => preset.id !== 'custom' && preset.provider === provider && preset.baseUrl === baseUrl);
|
|
2820
|
+
/**
|
|
2821
|
+
* The assistant's settings: what is connected and whether it answers (Test, its result in place),
|
|
2822
|
+
* then the form, one provider chosen from tiles, and Disconnect apart. A member sees only what is
|
|
2823
|
+
* connected, and where their messages go.
|
|
2824
|
+
*/
|
|
2825
|
+
async function assistantSettingsSection(session, status, notice, draft) {
|
|
2469
2826
|
const store = assistantStore();
|
|
2470
|
-
if (!store)
|
|
2471
|
-
return html ``;
|
|
2472
2827
|
const settings = await store.getSettings(tenantOf(session));
|
|
2473
|
-
const sentTo = html `<p class="fine">Members' messages, and the bookings and resources the assistant reads for them, are sent to this provider.</p>`;
|
|
2474
|
-
if (!isAdmin(session)) {
|
|
2475
|
-
return html `<section class="panel" id="assistant"><h2>Assistant</h2>${settings
|
|
2476
|
-
? html `<dl class="facts"><dt>Provider</dt><dd>${settings.provider === 'anthropic' ? 'Anthropic' : 'OpenAI-compatible'}</dd><dt>Model</dt><dd>${settings.model}</dd></dl>${sentTo}`
|
|
2477
|
-
: html `<p class="empty">No model is connected. An admin can connect one.</p>`}</section>`;
|
|
2478
|
-
}
|
|
2479
2828
|
const token = csrfToken(session);
|
|
2829
|
+
const hidden = () => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}">`;
|
|
2830
|
+
const savedName = settings ? (presetOf(settings.provider, settings.baseUrl)?.name ?? 'Custom') : null;
|
|
2831
|
+
// Where messages go, in words: the provider's name, or a custom one's host.
|
|
2832
|
+
const hostOf = (url) => {
|
|
2833
|
+
try {
|
|
2834
|
+
return new URL(url).host;
|
|
2835
|
+
}
|
|
2836
|
+
catch {
|
|
2837
|
+
return url;
|
|
2838
|
+
}
|
|
2839
|
+
};
|
|
2840
|
+
const savedTo = settings ? (savedName === 'Custom' ? hostOf(settings.baseUrl) : savedName) : null;
|
|
2841
|
+
const admin = isAdmin(session);
|
|
2842
|
+
const badge = !settings
|
|
2843
|
+
? html `<span class="badge">Not set up</span>`
|
|
2844
|
+
: notice && status === 200 && notice.startsWith('Connected')
|
|
2845
|
+
? html `<span class="badge ok">Connected</span>`
|
|
2846
|
+
: notice && status === 502
|
|
2847
|
+
? html `<span class="badge bad">Failing</span>`
|
|
2848
|
+
: html `<span class="badge ok">Set up</span>`;
|
|
2849
|
+
const test = settings && admin
|
|
2850
|
+
? html `<form method="post" action="${root}/settings/assistant/test">${hidden()}<button type="submit">Test</button></form>`
|
|
2851
|
+
: html ``;
|
|
2852
|
+
const statusCard = html `<div class="set-card status-card" id="assistant"><div class="status-head"><span class="mono-tile" aria-hidden="true">${savedName ? savedName.charAt(0) : '–'}</span><div class="status-name"><strong>${savedName ?? 'No model connected'}</strong>${settings ? html `<code>${settings.model}</code>` : html ``}</div>${badge}${test}</div>${noticeOf(notice, status)}</div>`;
|
|
2853
|
+
const sentTo = (name) => html `<p class="fine">Members' messages, and the bookings they ask about, are sent to ${name ?? 'the provider you choose'}.</p>`;
|
|
2854
|
+
if (!admin) {
|
|
2855
|
+
return html `${statusCard}${settings ? sentTo(savedTo) : html `<p class="fine">An admin of this organization connects a model here.</p>`}`;
|
|
2856
|
+
}
|
|
2480
2857
|
// What the form shows: a draft (a preset, or a model list) over what is saved.
|
|
2481
|
-
const provider = draft?.provider ?? settings?.provider;
|
|
2858
|
+
const provider = draft?.provider ?? settings?.provider ?? 'anthropic';
|
|
2482
2859
|
const baseUrl = draft ? draft.baseUrl : (settings?.baseUrl ?? '');
|
|
2483
2860
|
const model = draft?.model ?? settings?.model ?? '';
|
|
2484
|
-
const
|
|
2861
|
+
const chosen = settings || draft ? (presetOf(provider, baseUrl)?.id ?? 'custom') : 'anthropic';
|
|
2862
|
+
const chosenName = chosen === 'custom' ? (baseUrl ? hostOf(baseUrl) : null) : (SLOTLOCK_ASSISTANT_PRESETS.find((preset) => preset.id === chosen)?.name ?? null);
|
|
2485
2863
|
const models = draft?.models ?? [];
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
<input
|
|
2492
|
-
<div class="grid-form">
|
|
2493
|
-
<label>Provider<select name="provider"><option value="anthropic"${selected('anthropic')}>Anthropic Messages API</option><option value="openai"${selected('openai')}>OpenAI-compatible</option></select></label>
|
|
2494
|
-
<label>Base URL<input name="base_url" required maxlength="2048" value="${baseUrl}" placeholder="https://api.anthropic.com"></label>
|
|
2495
|
-
<label>Model<input name="model" required maxlength="200" value="${model}" placeholder="your-model"${models.length > 0 ? html ` list="assistant-models"` : html ``}></label>${models.length > 0
|
|
2864
|
+
const tiles = html `<div class="tiles" role="radiogroup" aria-label="Provider">${SLOTLOCK_ASSISTANT_PRESETS.map((preset) => html `<label class="tile"><input type="radio" name="preset" value="${preset.id}"${preset.id === chosen ? html ` checked` : html ``}><span class="tile-mark" aria-hidden="true">${preset.name.charAt(0)}</span>${preset.name}</label>`)}</div>`;
|
|
2865
|
+
const format = (value, label) => html `<label><input type="radio" name="provider" value="${value}"${provider === value ? html ` checked` : html ``}>${label}</label>`;
|
|
2866
|
+
const keyField = settings?.keyHint
|
|
2867
|
+
? html `<div class="key-set"><code>•••• ${settings.keyHint}</code><details><summary>Replace</summary><input id="api-key" name="api_key" type="password" autocomplete="off" maxlength="4096" aria-label="New API key"></details></div>`
|
|
2868
|
+
: html `<input id="api-key" name="api_key" type="password" autocomplete="off" maxlength="4096" placeholder="Optional for a local model">`;
|
|
2869
|
+
const form = html `<form class="set-card provider-form" method="post" action="${root}/settings/assistant">${hidden()}<div class="row stack"><span class="row-label">Provider</span>${tiles}</div><div class="row custom-only"><span class="row-label">API format</span><div class="segmented">${format('anthropic', 'Anthropic Messages')}${format('openai', 'OpenAI-compatible')}</div></div><div class="row custom-only"><label class="row-label" for="base-url">Base URL</label><input id="base-url" class="mono" name="base_url" maxlength="2048" value="${baseUrl}" placeholder="https://host/v1"></div><div class="row"><label class="row-label" for="model">Model</label><div class="field-with-button"><input id="model" class="mono" name="model" required maxlength="200" value="${model}" placeholder="Model name"${models.length > 0 ? html ` list="assistant-models"` : html ``}><button type="submit" formaction="${root}/settings/assistant/models" formnovalidate>Load models</button></div>${models.length > 0
|
|
2496
2870
|
? html `<datalist id="assistant-models">${models.map((id) => html `<option value="${id}"></option>`)}</datalist>`
|
|
2497
|
-
: html ``}
|
|
2498
|
-
|
|
2499
|
-
</div>
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
:
|
|
2508
|
-
|
|
2871
|
+
: html ``}</div><div class="row"><label class="row-label" for="api-key">API key</label>${keyField}</div><div class="card-foot">${sentTo(chosenName)}<button class="primary" type="submit">Save</button></div></form>`;
|
|
2872
|
+
const disconnect = settings
|
|
2873
|
+
? html `<div class="set-card"><div class="danger-row"><strong>Disconnect ${savedName}</strong><details class="act danger"><summary>Disconnect…</summary><div class="pop"><p>The key is deleted. Members can't use the assistant until a model is connected again.</p><form method="post" action="${root}/settings/assistant/remove">${hidden()}<button class="danger" type="submit">Disconnect</button></form></div></details></div></div>`
|
|
2874
|
+
: html ``;
|
|
2875
|
+
return html `${statusCard}${form}${disconnect}`;
|
|
2876
|
+
}
|
|
2877
|
+
/** The provider and base URL a form names: a named provider's own, or Custom's two fields. */
|
|
2878
|
+
function formEndpoint(form) {
|
|
2879
|
+
const preset = SLOTLOCK_ASSISTANT_PRESETS.find((candidate) => candidate.id === form.get('preset'));
|
|
2880
|
+
if (preset && preset.id !== 'custom')
|
|
2881
|
+
return { provider: preset.provider, baseUrl: preset.baseUrl };
|
|
2882
|
+
return { provider: form.get('provider'), baseUrl: (form.get('base_url') ?? '').trim() };
|
|
2509
2883
|
}
|
|
2510
2884
|
const adminOnly = (session) => settingsPage(session, 403, 'Only an owner of this organization can change the assistant.');
|
|
2511
2885
|
async function saveAssistantSettings(session, form) {
|
|
@@ -2514,8 +2888,8 @@ ${settings
|
|
|
2514
2888
|
return notFound(session);
|
|
2515
2889
|
if (!isAdmin(session))
|
|
2516
2890
|
return adminOnly(session);
|
|
2517
|
-
const provider = form
|
|
2518
|
-
const checkedUrl = readSlotlockAssistantBaseUrl(
|
|
2891
|
+
const { provider, baseUrl: givenUrl } = formEndpoint(form);
|
|
2892
|
+
const checkedUrl = readSlotlockAssistantBaseUrl(givenUrl, { privateUrls });
|
|
2519
2893
|
const model = (form.get('model') ?? '').trim();
|
|
2520
2894
|
const apiKey = (form.get('api_key') ?? '').trim();
|
|
2521
2895
|
if (provider !== 'anthropic' && provider !== 'openai') {
|
|
@@ -2566,14 +2940,14 @@ ${settings
|
|
|
2566
2940
|
if (!memberMessages.take(`${tenantOf(session)}\0${session.uid}`)) {
|
|
2567
2941
|
return tooManyMessages(session, settingsPage);
|
|
2568
2942
|
}
|
|
2569
|
-
const provider = form
|
|
2943
|
+
const { provider, baseUrl: givenUrl } = formEndpoint(form);
|
|
2570
2944
|
if (provider !== 'anthropic' && provider !== 'openai') {
|
|
2571
2945
|
return settingsPage(session, 400, 'Choose a provider from the list.');
|
|
2572
2946
|
}
|
|
2573
2947
|
const typedModel = (form.get('model') ?? '').trim();
|
|
2574
2948
|
const draft = {
|
|
2575
2949
|
provider,
|
|
2576
|
-
baseUrl:
|
|
2950
|
+
baseUrl: givenUrl,
|
|
2577
2951
|
...(typedModel !== '' ? { model: typedModel } : {}),
|
|
2578
2952
|
};
|
|
2579
2953
|
const checkedUrl = readSlotlockAssistantBaseUrl(draft.baseUrl, { privateUrls });
|
|
@@ -2655,22 +3029,34 @@ ${settings
|
|
|
2655
3029
|
return settingsPage(session, 502, slotlockAssistantProviderMessage(error));
|
|
2656
3030
|
}
|
|
2657
3031
|
}
|
|
2658
|
-
|
|
3032
|
+
/**
|
|
3033
|
+
* One member message: check it, run the turn (streaming its text and steps to `onEvent` when
|
|
3034
|
+
* given, stopping when `signal` aborts), and keep the conversation. Both ways to send share it.
|
|
3035
|
+
*/
|
|
3036
|
+
async function runAssistantMessage(session, form, hooks = {}) {
|
|
2659
3037
|
const store = assistantStore();
|
|
2660
3038
|
if (!store || !assistantOptions)
|
|
2661
|
-
return
|
|
3039
|
+
return { ok: false, status: 404, message: 'Nothing is here.' };
|
|
2662
3040
|
const text = (form.get('message') ?? '').trim();
|
|
2663
3041
|
if (text === '' || text.length > SLOTLOCK_ASSISTANT_MAX_MESSAGE) {
|
|
2664
|
-
return
|
|
3042
|
+
return {
|
|
3043
|
+
ok: false,
|
|
3044
|
+
status: 400,
|
|
3045
|
+
message: `Write a message of 1 to ${SLOTLOCK_ASSISTANT_MAX_MESSAGE.toLocaleString('en')} characters.`,
|
|
3046
|
+
};
|
|
2665
3047
|
}
|
|
2666
3048
|
if (!memberMessages.take(`${tenantOf(session)}\0${session.uid}`)) {
|
|
2667
|
-
return
|
|
3049
|
+
return {
|
|
3050
|
+
ok: false,
|
|
3051
|
+
status: 429,
|
|
3052
|
+
message: 'You have sent the assistant a lot in the last minute. Wait a moment and try again.',
|
|
3053
|
+
};
|
|
2668
3054
|
}
|
|
2669
3055
|
const found = await assistantModel(session);
|
|
2670
3056
|
if (found === null)
|
|
2671
|
-
return
|
|
3057
|
+
return { ok: false, status: 409, message: 'No model is connected.' };
|
|
2672
3058
|
if ('problem' in found)
|
|
2673
|
-
return
|
|
3059
|
+
return { ok: false, status: 409, message: found.problem };
|
|
2674
3060
|
const tenantRef = tenantOf(session);
|
|
2675
3061
|
const member = personOf(session);
|
|
2676
3062
|
try {
|
|
@@ -2702,6 +3088,8 @@ ${settings
|
|
|
2702
3088
|
timeoutMs: assistantTimeout,
|
|
2703
3089
|
turnTimeoutMs: assistantTurnTimeout,
|
|
2704
3090
|
...(assistantOptions.maxSteps !== undefined ? { maxSteps: assistantOptions.maxSteps } : {}),
|
|
3091
|
+
...(hooks.onEvent ? { onEvent: hooks.onEvent } : {}),
|
|
3092
|
+
...(hooks.signal ? { signal: hooks.signal } : {}),
|
|
2705
3093
|
read: async (operation, input) => {
|
|
2706
3094
|
// A turn's reads share one budget of resource-days, so 8 steps of wide reads cannot each
|
|
2707
3095
|
// scan a hundred resources over years.
|
|
@@ -2758,8 +3146,85 @@ ${settings
|
|
|
2758
3146
|
added: Object.fromEntries(Object.entries(proposed).filter(([id]) => id in kept)),
|
|
2759
3147
|
keep: Object.keys(kept),
|
|
2760
3148
|
}, conversationTtl);
|
|
3149
|
+
return { ok: true, messages, actions: kept };
|
|
3150
|
+
}
|
|
3151
|
+
/** The form's way (no script): run the turn, then show the conversation at its latest message. */
|
|
3152
|
+
async function sendToAssistant(session, form) {
|
|
3153
|
+
const run = await runAssistantMessage(session, form);
|
|
3154
|
+
if (!run.ok)
|
|
3155
|
+
return run.status === 404 ? notFound(session) : assistantPage(session, run.status, run.message);
|
|
2761
3156
|
return redirect(303, `${root}/assistant#latest`);
|
|
2762
3157
|
}
|
|
3158
|
+
/**
|
|
3159
|
+
* app.js's way: the same turn, answered as it happens, one JSON object a line. `text` carries a
|
|
3160
|
+
* piece of the reply, `step` a tool step as it starts and ends, and the last line is `done` (the
|
|
3161
|
+
* turn as the page renders it, which replaces what was streamed) or `error`, each with a fresh
|
|
3162
|
+
* single-use value for the next send. The member's Stop aborts the request, and the turn with it.
|
|
3163
|
+
*/
|
|
3164
|
+
async function streamToAssistant(session, form, request) {
|
|
3165
|
+
const encoder = new TextEncoder();
|
|
3166
|
+
const pipe = new TransformStream();
|
|
3167
|
+
const writer = pipe.writable.getWriter();
|
|
3168
|
+
let open = true;
|
|
3169
|
+
const write = (event) => {
|
|
3170
|
+
if (!open)
|
|
3171
|
+
return;
|
|
3172
|
+
writer.write(encoder.encode(`${JSON.stringify(event)}\n`)).catch(() => {
|
|
3173
|
+
open = false;
|
|
3174
|
+
});
|
|
3175
|
+
};
|
|
3176
|
+
const label = (name, state) => slotlockAssistantStepLabel(name, toolTitles.get(name) ?? name, state, SLOTLOCK_ASSISTANT_TOOL_USE[name] === 'propose');
|
|
3177
|
+
const failed = (message) => write({
|
|
3178
|
+
t: 'error',
|
|
3179
|
+
html: html `<li class="turn error" role="alert"><span>${message}</span></li>`.value,
|
|
3180
|
+
once: formNonce(session),
|
|
3181
|
+
});
|
|
3182
|
+
const work = (async () => {
|
|
3183
|
+
try {
|
|
3184
|
+
const run = await runAssistantMessage(session, form, {
|
|
3185
|
+
signal: request.signal,
|
|
3186
|
+
onEvent: (event) => {
|
|
3187
|
+
if (event.type === 'text')
|
|
3188
|
+
write({ t: 'text', d: event.delta });
|
|
3189
|
+
else if (event.type === 'call')
|
|
3190
|
+
write({ t: 'step', id: event.id, s: 'run', label: label(event.name, 'run') });
|
|
3191
|
+
else
|
|
3192
|
+
write({ t: 'step', id: event.id, s: event.ok ? 'ok' : 'bad', label: label(event.name, event.ok ? 'ok' : 'bad') });
|
|
3193
|
+
},
|
|
3194
|
+
});
|
|
3195
|
+
if (!run.ok) {
|
|
3196
|
+
failed(run.message);
|
|
3197
|
+
return;
|
|
3198
|
+
}
|
|
3199
|
+
// The turn from the member's message on, as a reload would show it.
|
|
3200
|
+
const start = run.messages.map((entry) => entry.role).lastIndexOf('user');
|
|
3201
|
+
const turn = run.messages.slice(Math.max(0, start));
|
|
3202
|
+
const lookups = Object.keys(run.actions).length > 0
|
|
3203
|
+
? await resourceLookups(tenantOf(session))
|
|
3204
|
+
: { names: new Map(), zones: new Map() };
|
|
3205
|
+
write({
|
|
3206
|
+
t: 'done',
|
|
3207
|
+
html: assistantItems(session, turn, run.actions, lookups.names, lookups.zones).join(''),
|
|
3208
|
+
once: formNonce(session),
|
|
3209
|
+
});
|
|
3210
|
+
}
|
|
3211
|
+
catch (error) {
|
|
3212
|
+
report(error instanceof Error ? error.message : 'assistant stream failed');
|
|
3213
|
+
failed('Something went wrong. Reload the page and try again.');
|
|
3214
|
+
}
|
|
3215
|
+
finally {
|
|
3216
|
+
await writer.close().catch(() => undefined);
|
|
3217
|
+
open = false;
|
|
3218
|
+
}
|
|
3219
|
+
})();
|
|
3220
|
+
ignoreRejection(work);
|
|
3221
|
+
const page = respond(200, '');
|
|
3222
|
+
const headers = new Headers(page.headers);
|
|
3223
|
+
headers.set('Content-Type', 'application/x-ndjson; charset=utf-8');
|
|
3224
|
+
// Proxies that buffer (nginx) send each line as it is written.
|
|
3225
|
+
headers.set('X-Accel-Buffering', 'no');
|
|
3226
|
+
return new Response(pipe.readable, { status: 200, headers });
|
|
3227
|
+
}
|
|
2763
3228
|
async function settleAction(session, form, decision) {
|
|
2764
3229
|
const store = assistantStore();
|
|
2765
3230
|
if (!store || !assistantOptions)
|
|
@@ -2807,80 +3272,173 @@ ${settings
|
|
|
2807
3272
|
await store.deleteConversation(tenantOf(session), personOf(session));
|
|
2808
3273
|
return redirect(303, `${root}/assistant`);
|
|
2809
3274
|
}
|
|
2810
|
-
|
|
3275
|
+
/**
|
|
3276
|
+
* A workspace's plan as both the Usage page and the overview show it: the plan (or the default it
|
|
3277
|
+
* moves to), what it has against the limits, this month's use against the quotas, and what needs a
|
|
3278
|
+
* person: a failed payment, a quota used up or nearly, more than the coming plan allows.
|
|
3279
|
+
* `here` is the page asking, so an item's action points to the page that resolves it.
|
|
3280
|
+
*/
|
|
3281
|
+
async function planStanding(session, here, given) {
|
|
3282
|
+
const active = plans;
|
|
2811
3283
|
const billing = options.billing;
|
|
2812
|
-
if (!billing)
|
|
2813
|
-
return notFound(session);
|
|
2814
|
-
if (session.env === 'test') {
|
|
2815
|
-
// Its own use, counted apart from Live's and never billed.
|
|
2816
|
-
const range = slotlockUsageMonthToDate(now());
|
|
2817
|
-
const tested = options.usage && options.resources.usage
|
|
2818
|
-
? summarizeSlotlockUsage(await options.resources.usage(tenantOf(session), range.from, range.to))
|
|
2819
|
-
: null;
|
|
2820
|
-
const stat = (label, value) => html `<div class="stat"><span class="stat-label">${label}</span><span class="stat-value">${value.toLocaleString('en-GB')}</span></div>`;
|
|
2821
|
-
return respond(status, layout('Plan', html `${pageHead('Plan')}${noticeOf(notice)}<p>The test environment is on your live plan's limits and is never billed. Switch to Live to see your plan, or to change it.</p>${tested
|
|
2822
|
-
? html `<section class="panel" id="usage"><h2>Test usage this month</h2><div class="stats">${stat('Operations', tested.totals.operations)}${stat('Bookings', tested.totals.bookings)}${stat('Holds', tested.totals.holds)}</div><p class="fine">Since ${range.from}, in UTC, counted apart from Live, against the same monthly quotas.</p></section>`
|
|
2823
|
-
: html ``}`, session, 'plan'));
|
|
2824
|
-
}
|
|
2825
3284
|
const tenantRef = tenantOf(session);
|
|
2826
|
-
const [plan, resourceCount, keys, webhooks] = await Promise.all([
|
|
2827
|
-
|
|
3285
|
+
const [plan, planDefault, resourceCount, keys, webhooks] = await Promise.all([
|
|
3286
|
+
active.plan(tenantRef),
|
|
3287
|
+
active.planDefault ? active.planDefault() : Promise.resolve(null),
|
|
2828
3288
|
// As the limit counts them: archived resources included.
|
|
2829
3289
|
options.resources.count
|
|
2830
3290
|
? options.resources.count(tenantRef)
|
|
2831
3291
|
: options.resources.list(tenantRef).then((listed) => listed.length),
|
|
2832
|
-
options.keys.list({ tenantRef }),
|
|
3292
|
+
given ?? options.keys.list({ tenantRef }),
|
|
2833
3293
|
options.resources.webhooks?.list(tenantRef),
|
|
2834
3294
|
]);
|
|
2835
3295
|
const month = slotlockUsageMonthToDate(now());
|
|
2836
3296
|
const used = options.usage && options.resources.usage
|
|
2837
3297
|
? summarizeSlotlockUsage(await options.resources.usage(tenantRef, month.from, month.to))
|
|
2838
3298
|
: null;
|
|
2839
|
-
const
|
|
2840
|
-
const
|
|
2841
|
-
const token = csrfToken(session);
|
|
3299
|
+
const keyCount = keys.filter((key) => !key.revokedAt && (key.expiresAt === null || key.expiresAt.getTime() > now())).length;
|
|
3300
|
+
const admin = isAdmin(session);
|
|
2842
3301
|
const paying = plan !== null && plan.plan !== 'free';
|
|
2843
|
-
const
|
|
2844
|
-
|
|
2845
|
-
const offered = session.org !== undefined && billing.teamPrices ? 'team' : 'developer';
|
|
2846
|
-
const offeredPrices = offered === 'team' ? billing.teamPrices : billing.prices;
|
|
2847
|
-
const counted = (value) => value.toLocaleString('en-GB');
|
|
2848
|
-
const use = (label, used, limit) => html `<div class="stat"><span class="stat-label">${label}</span><span class="stat-value">${counted(used)}${limit === undefined ? html `` : html `<span class="meta"> of ${counted(limit)}</span>`}</span></div>`;
|
|
2849
|
-
// This month's quotas, when the deployment holds tenants to them.
|
|
3302
|
+
const upcoming = !plan && planDefault && planDefault.appliesFrom.getTime() > now() ? planDefault : null;
|
|
3303
|
+
const limits = plan?.limits ?? upcoming?.limits;
|
|
2850
3304
|
const quotas = options.quotas && plan ? SLOTLOCK_PLAN_QUOTAS[plan.plan] : null;
|
|
2851
3305
|
const resets = formatDate(slotlockQuotaPeriod(now()).resetsAt);
|
|
2852
|
-
const
|
|
2853
|
-
|
|
3306
|
+
const counted = (value) => value.toLocaleString('en-GB');
|
|
3307
|
+
const usagePage = `${root}/plan`;
|
|
3308
|
+
const upgradeAt = here === 'usage' ? '#upgrade' : `${usagePage}#upgrade`;
|
|
3309
|
+
const canUpgrade = !paying && admin && Boolean(billing || active.contact);
|
|
3310
|
+
const upgradeHint = paying ? '' : admin ? ' Upgrade for more.' : ' An owner of this organization can upgrade.';
|
|
3311
|
+
const attention = [];
|
|
3312
|
+
// Whether anything is near or past a limit: the overview then offers the upgrade.
|
|
3313
|
+
let pressing = false;
|
|
3314
|
+
if (plan?.status === 'past_due') {
|
|
3315
|
+
attention.push(attentionItem('bad', `Your last payment failed. ${admin && billing && plan.stripeCustomer ? 'Update your payment method in Manage billing' : 'An owner updates the payment method'} to keep the ${PLAN_NAMES[plan.plan]} plan.`, here === 'overview' ? { href: usagePage, label: 'Usage' } : undefined));
|
|
3316
|
+
}
|
|
3317
|
+
if (quotas && used) {
|
|
3318
|
+
for (const metric of ['operations', 'bookings']) {
|
|
2854
3319
|
const quota = quotas[metric];
|
|
2855
3320
|
const stop = Math.floor((quota * quotas.stopPercent) / 100);
|
|
2856
3321
|
const spent = used.totals[metric];
|
|
2857
|
-
const what = metric === 'operations' ? 'operations' : 'bookings';
|
|
2858
3322
|
const refused = metric === 'operations' ? 'every call but reading usage' : 'new bookings and holds';
|
|
3323
|
+
const upgrade = canUpgrade ? { href: upgradeAt, label: 'Upgrade' } : here === 'overview' ? { href: usagePage, label: 'Usage' } : undefined;
|
|
3324
|
+
if (spent * 100 >= quota * 80)
|
|
3325
|
+
pressing = true;
|
|
2859
3326
|
if (spent >= stop) {
|
|
2860
|
-
|
|
3327
|
+
attention.push(attentionItem('bad', `This month's ${metric} are used up: Slotlock refuses ${refused} with quota_exceeded until ${resets}.${upgradeHint}`, upgrade));
|
|
2861
3328
|
}
|
|
2862
|
-
if (spent >= quota) {
|
|
2863
|
-
const billed = metric === 'operations' && billing
|
|
2864
|
-
|
|
3329
|
+
else if (spent >= quota) {
|
|
3330
|
+
const billed = metric === 'operations' && billing?.overage && plan?.plan === 'developer' && paying;
|
|
3331
|
+
attention.push(attentionItem('warn', `You have used this month's ${counted(quota)} ${metric}.${billed ? ` Those past it are billed at ${billing?.overage?.price ?? ''}.` : ''} Slotlock refuses ${refused} past ${counted(stop)}, until ${resets}.${billed ? '' : upgradeHint}`, billed ? undefined : upgrade));
|
|
2865
3332
|
}
|
|
2866
|
-
if (spent * 100 >= quota * 80) {
|
|
2867
|
-
|
|
3333
|
+
else if (spent * 100 >= quota * 80) {
|
|
3334
|
+
attention.push(attentionItem('warn', `You have used ${String(Math.floor((spent * 100) / quota))}% of this month's ${metric}. They start again on ${resets}.`, here === 'overview' ? { href: usagePage, label: 'Usage' } : undefined));
|
|
2868
3335
|
}
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
3336
|
+
}
|
|
3337
|
+
}
|
|
3338
|
+
if (upcoming) {
|
|
3339
|
+
// What the workspace has that its coming plan will not allow: nothing is removed; adding stops.
|
|
3340
|
+
const over = [
|
|
3341
|
+
['resources', resourceCount, upcoming.limits.resources],
|
|
3342
|
+
['active API keys', keyCount, upcoming.limits.apiKeys],
|
|
3343
|
+
['webhooks', webhooks?.length ?? 0, upcoming.limits.webhookEndpoints],
|
|
3344
|
+
].filter(([, have, allowed]) => have > allowed);
|
|
3345
|
+
if (over.length > 0)
|
|
3346
|
+
pressing = true;
|
|
3347
|
+
attention.push(attentionItem(over.length > 0 ? 'warn' : 'info', `From ${formatDate(upcoming.appliesFrom)}, this workspace is on ${PLAN_NAMES[upcoming.plan]}: ${String(upcoming.limits.resources)} resources, ${String(upcoming.limits.apiKeys)} API keys and ${String(upcoming.limits.webhookEndpoints)} webhooks.${over.length > 0
|
|
3348
|
+
? ` You have ${over.map(([what, have]) => `${String(have)} ${what}`).join(', ')}: nothing is removed, but you cannot add more until you have fewer${paying || !admin ? '' : ', or upgrade'}.`
|
|
3349
|
+
: ''}`, canUpgrade && over.length > 0 ? { href: upgradeAt, label: 'See plans' } : undefined));
|
|
3350
|
+
}
|
|
3351
|
+
/** One use against its allowance: a bar, amber from 80%, red at the limit. */
|
|
3352
|
+
const meter = (label, value, limit) => {
|
|
3353
|
+
if (limit === undefined) {
|
|
3354
|
+
return html `<div class="meter"><div class="meter-head"><span>${label}</span><span class="meter-value">${counted(value)}</span></div></div>`;
|
|
3355
|
+
}
|
|
3356
|
+
const tone = value >= limit ? ' bad' : value * 100 >= limit * 80 ? ' warn' : '';
|
|
3357
|
+
return html `<div class="meter${tone}"><div class="meter-head"><span>${label}</span><span class="meter-value">${counted(value)}<span class="meta"> of ${counted(limit)}</span></span></div><progress value="${String(Math.min(value, limit))}" max="${String(limit)}" aria-label="${label}: ${counted(value)} of ${counted(limit)}"></progress></div>`;
|
|
3358
|
+
};
|
|
3359
|
+
const limitMeters = html `${meter('Resources', resourceCount, limits?.resources)}${meter('Active API keys', keyCount, limits?.apiKeys)}${webhooks ? meter('Webhooks', webhooks.length, limits?.webhookEndpoints) : html ``}`;
|
|
3360
|
+
// Under a "This month" heading, or beside the limits with the month in each label.
|
|
3361
|
+
const monthMeters = (suffix) => used
|
|
3362
|
+
? html `${meter(`Operations${suffix}`, used.totals.operations, quotas?.operations)}${meter(`Bookings${suffix}`, used.totals.bookings, quotas?.bookings)}${meter(`Holds${suffix}`, used.totals.holds, undefined)}`
|
|
3363
|
+
: html ``;
|
|
3364
|
+
const name = plan
|
|
3365
|
+
? PLAN_NAMES[plan.plan]
|
|
3366
|
+
: upcoming
|
|
3367
|
+
? `${PLAN_NAMES[upcoming.plan]} from ${dayOf(upcoming.appliesFrom)}`
|
|
3368
|
+
: 'No plan';
|
|
3369
|
+
return {
|
|
3370
|
+
plan,
|
|
3371
|
+
upcoming,
|
|
3372
|
+
paying,
|
|
3373
|
+
admin,
|
|
3374
|
+
used,
|
|
3375
|
+
month,
|
|
3376
|
+
resets,
|
|
3377
|
+
keys,
|
|
3378
|
+
name,
|
|
3379
|
+
limitMeters,
|
|
3380
|
+
monthMeters,
|
|
3381
|
+
meters: html `${limitMeters}${monthMeters(' this month')}`,
|
|
3382
|
+
attention,
|
|
3383
|
+
canUpgrade,
|
|
3384
|
+
pressing,
|
|
3385
|
+
};
|
|
3386
|
+
}
|
|
3387
|
+
/** One thing that needs a person: a sentence, its tone, and what resolves it. */
|
|
3388
|
+
const attentionItem = (tone, text, action) => html `<li class="attention ${tone}"><span class="attention-text">${text}</span>${action ? html `<a class="attention-action" href="${action.href}">${action.label}</a>` : html ``}</li>`;
|
|
3389
|
+
/** The list of things that need a person, or nothing. */
|
|
3390
|
+
const attentionList = (items) => items.length > 0 ? html `<ul class="attention-list" aria-label="Needs attention">${items}</ul>` : html ``;
|
|
3391
|
+
async function planPage(session, status = 200, notice, thanks = false) {
|
|
3392
|
+
if (!plans)
|
|
3393
|
+
return notFound(session);
|
|
3394
|
+
const billing = options.billing;
|
|
3395
|
+
const counted = (value) => value.toLocaleString('en-GB');
|
|
3396
|
+
if (session.env === 'test') {
|
|
3397
|
+
// Its own use, counted apart from Live's and never billed.
|
|
3398
|
+
const range = slotlockUsageMonthToDate(now());
|
|
3399
|
+
const tested = options.usage && options.resources.usage
|
|
3400
|
+
? summarizeSlotlockUsage(await options.resources.usage(tenantOf(session), range.from, range.to))
|
|
3401
|
+
: null;
|
|
3402
|
+
const meter = (label, value) => html `<div class="meter"><div class="meter-head"><span>${label}</span><span class="meter-value">${counted(value)}</span></div></div>`;
|
|
3403
|
+
return respond(status, layout('Usage', html `${pageHead('Usage')}${noticeOf(notice, status)}<p>The test environment is on your live plan's limits and is never billed. Switch to Live to see your plan, or to change it.</p>${tested
|
|
3404
|
+
? html `<section class="panel" id="usage"><h2>This month, in Test</h2><div class="meters">${meter('Operations', tested.totals.operations)}${meter('Bookings', tested.totals.bookings)}${meter('Holds', tested.totals.holds)}</div><p class="fine">Since ${range.from}, in UTC, counted apart from Live, against the same monthly quotas.</p></section>`
|
|
3405
|
+
: html ``}`, session, 'plan'));
|
|
3406
|
+
}
|
|
3407
|
+
const standing = await planStanding(session, 'usage');
|
|
3408
|
+
const { plan, paying, admin, used, month, resets, keys } = standing;
|
|
3409
|
+
const token = csrfToken(session);
|
|
3410
|
+
const keyName = new Map(keys.map((key) => [key.id, key.name]));
|
|
3411
|
+
// An organization is offered Team (where billing sells it, or by asking); a person, Developer.
|
|
3412
|
+
const offered = session.org !== undefined && (billing ? Boolean(billing.teamPrices) : true) ? 'team' : 'developer';
|
|
3413
|
+
const contact = plans.contact ?? billing?.contact;
|
|
3414
|
+
const manage = billing && plan?.stripeCustomer && admin
|
|
3415
|
+
? html `<form class="inline" method="post" action="${root}/plan/manage"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><button type="submit">Manage billing</button></form>`
|
|
3416
|
+
: html ``;
|
|
3417
|
+
const renews = paying && plan?.currentPeriodEnd
|
|
3418
|
+
? html `<span class="meta">${plan.status === 'past_due' ? 'Payment due since' : 'Renews'} ${formatDate(plan.currentPeriodEnd)}</span>`
|
|
3419
|
+
: html ``;
|
|
3420
|
+
const planCard = html `<section class="panel plan-card" id="plan"><div class="plan-head"><h2>${standing.name}</h2>${renews}</div><div class="meters">${standing.limitMeters}</div>${used ? html `` : html `<p class="fine">This server does not count use yet.</p>`}</section>${used
|
|
3421
|
+
? html `<section class="panel" id="month"><h2>This month</h2><div class="meters">${standing.monthMeters('')}</div><p class="fine">Since ${month.from} (UTC); the month starts again on ${resets}. An operation is a call that reached Slotlock, whatever it answered.</p></section>`
|
|
3422
|
+
: html ``}`;
|
|
3423
|
+
const byKey = used && used.keys.length > 0
|
|
3424
|
+
? html `<section class="panel" id="usage"><h2>Used by</h2><div class="table"><table><thead><tr><th>Key</th><th>Operations</th><th>Bookings</th><th>Holds</th></tr></thead><tbody>${used.keys.map((row) => html `<tr><td data-label="Key">${row.keyId === null ? 'The dashboard and its assistant' : (keyName.get(row.keyId) ?? 'A deleted key')}</td><td data-label="Operations">${counted(row.operations)}</td><td data-label="Bookings">${counted(row.bookings)}</td><td data-label="Holds">${counted(row.holds)}</td></tr>`)}</tbody></table></div><p class="fine">Webhook deliveries ${counted(used.totals.webhook_deliveries)} · assistant messages ${counted(used.totals.assistant_messages)}.</p></section>`
|
|
3425
|
+
: html ``;
|
|
3426
|
+
const offeredLimits = SLOTLOCK_PLAN_LIMITS[offered];
|
|
3427
|
+
const offeredText = html `${String(offeredLimits.resources)} resources, ${String(offeredLimits.apiKeys)} API keys and ${String(offeredLimits.webhookEndpoints)} webhook endpoints${options.quotas
|
|
3428
|
+
? html `, with ${counted(SLOTLOCK_PLAN_QUOTAS[offered].operations)} operations and ${counted(SLOTLOCK_PLAN_QUOTAS[offered].bookings)} bookings a month${offered === 'team' ? ' shared by the organization' : ''}`
|
|
3429
|
+
: html ``}.`;
|
|
3430
|
+
let upgrade = html ``;
|
|
3431
|
+
if (!paying) {
|
|
3432
|
+
if (!admin) {
|
|
3433
|
+
upgrade = html `<p class="fine">An owner of this organization can change its plan.</p>`;
|
|
3434
|
+
}
|
|
3435
|
+
else if (billing) {
|
|
3436
|
+
const offeredPrices = offered === 'team' ? billing.teamPrices : billing.prices;
|
|
3437
|
+
upgrade = html `<section class="panel" id="upgrade">
|
|
2880
3438
|
<h2>${PLAN_NAMES[offered]}</h2>
|
|
2881
|
-
<p>${
|
|
2882
|
-
|
|
2883
|
-
|
|
3439
|
+
<p>${offeredText}${offered === 'developer' && billing.overage && options.quotas
|
|
3440
|
+
? ` Operations past them are billed at ${billing.overage.price}, up to ${String(billing.overage.stopPercent)}% of them.`
|
|
3441
|
+
: ''}</p>
|
|
2884
3442
|
<form method="post" action="${root}/plan/upgrade">
|
|
2885
3443
|
<input type="hidden" name="csrf" value="${token}">
|
|
2886
3444
|
<input type="hidden" name="once" value="${formNonce(session)}">
|
|
@@ -2892,46 +3450,22 @@ ${settings
|
|
|
2892
3450
|
</form>
|
|
2893
3451
|
<p class="fine">Paid through Stripe, in your currency, with tax added where it applies. Cancel at any time.</p>
|
|
2894
3452
|
</section>`;
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
const
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
${
|
|
2906
|
-
${use('Bookings', used.totals.bookings, quotas?.bookings)}
|
|
2907
|
-
${use('Holds', used.totals.holds, undefined)}
|
|
2908
|
-
${use('Webhook deliveries', used.totals.webhook_deliveries, undefined)}
|
|
2909
|
-
${use('Assistant messages', used.totals.assistant_messages, undefined)}
|
|
2910
|
-
</div>
|
|
2911
|
-
<p class="fine">Since ${month.from}, in UTC. An operation is a call that reached Slotlock, whatever it answered; it shows here within 15 seconds. A booking is a reservation, a calendar event or a confirmed hold, and with holds shows within the hour.</p>
|
|
2912
|
-
${used.keys.length === 0
|
|
2913
|
-
? html ``
|
|
2914
|
-
: html `<div class="table"><table><thead><tr><th>Used by</th><th>Operations</th><th>Bookings</th><th>Holds</th></tr></thead><tbody>${used.keys.map((row) => html `<tr><td data-label="Used by">${row.keyId === null ? 'The dashboard and its assistant' : (keyName.get(row.keyId) ?? 'A deleted key')}</td><td data-label="Operations">${String(row.operations)}</td><td data-label="Bookings">${String(row.bookings)}</td><td data-label="Holds">${String(row.holds)}</td></tr>`)}</tbody></table></div>`}
|
|
2915
|
-
</section>`
|
|
2916
|
-
: html ``;
|
|
2917
|
-
const content = html `${thanks ? html `<p class="notice">${notice ?? ''}</p>` : noticeOf(notice)}
|
|
2918
|
-
${warning}
|
|
2919
|
-
${pageHead(`Plan: ${name}`, manage)}
|
|
2920
|
-
<div class="stats">
|
|
2921
|
-
${use('Resources', resourceCount, plan?.limits.resources)}
|
|
2922
|
-
${use('Active API keys', active.length, plan?.limits.apiKeys)}
|
|
2923
|
-
${webhooks ? use('Webhooks', webhooks.length, plan?.limits.webhookEndpoints) : html ``}
|
|
2924
|
-
</div>
|
|
2925
|
-
${renews}
|
|
2926
|
-
${usageSection}
|
|
2927
|
-
${plan ? html `` : html `<p class="fine">Free allows ${String(free.resources)} resources, ${String(free.apiKeys)} API keys and ${String(free.webhookEndpoints)} webhook endpoints${options.quotas ? html `, with ${counted(SLOTLOCK_PLAN_QUOTAS.free.operations)} operations and ${counted(SLOTLOCK_PLAN_QUOTAS.free.bookings)} bookings a month` : html ``}.</p>`}
|
|
3453
|
+
}
|
|
3454
|
+
else if (contact) {
|
|
3455
|
+
// No payment here yet: the plan is arranged by asking.
|
|
3456
|
+
upgrade = html `<section class="panel" id="upgrade"><h2>${PLAN_NAMES[offered]}</h2><p>${offeredText}</p><a class="button primary" href="${contact}">Talk to us about ${PLAN_NAMES[offered]}</a></section>`;
|
|
3457
|
+
}
|
|
3458
|
+
}
|
|
3459
|
+
const content = html `${thanks ? html `<p class="notice done" role="status">${notice ?? ''}</p>` : noticeOf(notice, status)}
|
|
3460
|
+
${pageHead('Usage', manage)}
|
|
3461
|
+
${attentionList(standing.attention)}
|
|
3462
|
+
${planCard}
|
|
3463
|
+
${byKey}
|
|
2928
3464
|
${upgrade}
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
</section>`;
|
|
2934
|
-
return respond(status, layout('Plan', content, session, 'plan'));
|
|
3465
|
+
${contact
|
|
3466
|
+
? html `<section class="panel" id="custom"><h2>Custom</h2><p>Slotlock on your own servers, with support from the people who build it.</p><a class="button" href="${contact}">Talk to the founder</a></section>`
|
|
3467
|
+
: html ``}`;
|
|
3468
|
+
return respond(status, layout('Usage', content, session, 'plan'));
|
|
2935
3469
|
}
|
|
2936
3470
|
async function upgradePlan(session, form) {
|
|
2937
3471
|
const billing = options.billing;
|
|
@@ -3007,7 +3541,7 @@ ${upgrade}
|
|
|
3007
3541
|
return assistantPage;
|
|
3008
3542
|
if (route.startsWith('/settings'))
|
|
3009
3543
|
return settingsPage;
|
|
3010
|
-
if (route.startsWith('/plan') &&
|
|
3544
|
+
if (route.startsWith('/plan') && plans)
|
|
3011
3545
|
return planPage;
|
|
3012
3546
|
if (route.startsWith('/teams') && teams)
|
|
3013
3547
|
return teamsPage;
|
|
@@ -3188,7 +3722,7 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
3188
3722
|
: html `<span class="muted">An owner can turn it on</span>`;
|
|
3189
3723
|
return html `<tr><td data-label="Organization" class="strong">${org.login}</td><td data-label="You are">${owner ? 'Owner' : 'Member'}</td><td data-label="Slotlock"><span class="pill ${on ? 'active' : 'revoked'}">${on ? 'On' : 'Off'}</span></td><td class="actions">${actions}</td></tr>`;
|
|
3190
3724
|
});
|
|
3191
|
-
const content = html `${noticeOf(notice)}${pageHead('Teams')}
|
|
3725
|
+
const content = html `${noticeOf(notice, status)}${pageHead('Teams')}
|
|
3192
3726
|
<p>Work with the people in a GitHub organization: once an owner turns Slotlock on for it, every owner and member shares its resources, keys and history, and picks it from the workspace menu. Your own workspace stays yours.</p>
|
|
3193
3727
|
${rows.length === 0
|
|
3194
3728
|
? emptyState(ICONS.teams, 'Slotlock sees none of your GitHub organizations. It reads them as you sign in: after joining one, sign out and in again. An organization that restricts OAuth apps needs an owner to approve this one.')
|
|
@@ -3632,9 +4166,9 @@ ${rows.length === 0
|
|
|
3632
4166
|
}
|
|
3633
4167
|
// Keys limited to part of the tenant come with Developer (RFC 0003 pricing); a server without
|
|
3634
4168
|
// plans allows them.
|
|
3635
|
-
const plan =
|
|
4169
|
+
const plan = plans ? await plans.plan(tenantOf(session)) : null;
|
|
3636
4170
|
if (plan?.plan === 'free') {
|
|
3637
|
-
return keysPage(session, 403, 'Keys limited to locations or groups come with the Developer and Team plans. See
|
|
4171
|
+
return keysPage(session, 403, 'Keys limited to locations or groups come with the Developer and Team plans. See Usage.');
|
|
3638
4172
|
}
|
|
3639
4173
|
}
|
|
3640
4174
|
try {
|
|
@@ -3659,8 +4193,8 @@ ${rows.length === 0
|
|
|
3659
4193
|
return keysPage(session, 400, 'Choose up to 20 locations and 20 groups from the lists.');
|
|
3660
4194
|
}
|
|
3661
4195
|
if (code === 'api_key_limit_reached') {
|
|
3662
|
-
return keysPage(session, 409,
|
|
3663
|
-
? 'You have as many active keys as your plan allows. Revoke one first, or see
|
|
4196
|
+
return keysPage(session, 409, plans
|
|
4197
|
+
? 'You have as many active keys as your plan allows. Revoke one first, or see Usage.'
|
|
3664
4198
|
: 'You have as many keys as Slotlock allows. Revoke one first.');
|
|
3665
4199
|
}
|
|
3666
4200
|
throw error;
|
|
@@ -3735,7 +4269,7 @@ ${rows.length === 0
|
|
|
3735
4269
|
if (code === 'resource_limit_reached') {
|
|
3736
4270
|
const limit = error.limit;
|
|
3737
4271
|
return resourcesPage(session, 409, typeof limit === 'number' && limit < SLOTLOCK_DASHBOARD_MAX_RESOURCES
|
|
3738
|
-
? `You have ${String(limit)} resources, archived ones included, as many as your plan allows. Delete one, or see
|
|
4272
|
+
? `You have ${String(limit)} resources, archived ones included, as many as your plan allows. Delete one, or see Usage.`
|
|
3739
4273
|
: `You have ${SLOTLOCK_DASHBOARD_MAX_RESOURCES} resources, as many as the dashboard allows.`);
|
|
3740
4274
|
}
|
|
3741
4275
|
if (code === 'invalid_timezone') {
|
|
@@ -3756,7 +4290,7 @@ ${rows.length === 0
|
|
|
3756
4290
|
const session = await readSession(request);
|
|
3757
4291
|
if (!session)
|
|
3758
4292
|
return redirect(303, root);
|
|
3759
|
-
const form = await readForm(request, route === '/assistant' ? MAX_ASSISTANT_FORM_BYTES : MAX_FORM_BYTES);
|
|
4293
|
+
const form = await readForm(request, route === '/assistant' || route === '/assistant/stream' ? MAX_ASSISTANT_FORM_BYTES : MAX_FORM_BYTES);
|
|
3760
4294
|
if (form === 'too_large') {
|
|
3761
4295
|
return respond(413, layout('Refused', html `<p class="notice">That form is too large.</p>`, session));
|
|
3762
4296
|
}
|
|
@@ -3794,12 +4328,15 @@ ${rows.length === 0
|
|
|
3794
4328
|
return pageFor(route)(session, 429, 'Too many forms sent from this account in the last 12 hours. Try again later.');
|
|
3795
4329
|
}
|
|
3796
4330
|
if (!first) {
|
|
4331
|
+
// The first one was taken: a second click, a reload or a resend shows what it did.
|
|
4332
|
+
if (route.startsWith('/assistant'))
|
|
4333
|
+
return redirect(303, `${root}/assistant#latest`);
|
|
3797
4334
|
return pageFor(route)(session, 409, 'That form was already sent. Reload the page to start again.');
|
|
3798
4335
|
}
|
|
3799
4336
|
}
|
|
3800
4337
|
// A tenant new to plans is put on the default plan before it adds what plans limit.
|
|
3801
4338
|
if (route === '/keys' || route === '/resources' || route === '/webhooks') {
|
|
3802
|
-
await
|
|
4339
|
+
await plans?.enrol?.(tenantOf(session));
|
|
3803
4340
|
}
|
|
3804
4341
|
switch (route) {
|
|
3805
4342
|
case '/sign-out':
|
|
@@ -3857,6 +4394,8 @@ ${rows.length === 0
|
|
|
3857
4394
|
return sandboxAction(session, form, 'fast-forward');
|
|
3858
4395
|
case '/assistant':
|
|
3859
4396
|
return sendToAssistant(session, form);
|
|
4397
|
+
case '/assistant/stream':
|
|
4398
|
+
return streamToAssistant(session, form, request);
|
|
3860
4399
|
case '/assistant/approve':
|
|
3861
4400
|
return settleAction(session, form, 'approve');
|
|
3862
4401
|
case '/assistant/dismiss':
|
|
@@ -3906,6 +4445,9 @@ ${rows.length === 0
|
|
|
3906
4445
|
case '/assistant':
|
|
3907
4446
|
case '/plan':
|
|
3908
4447
|
case '/teams':
|
|
4448
|
+
case '/settings/endpoints':
|
|
4449
|
+
case '/settings/hours':
|
|
4450
|
+
case '/settings/account':
|
|
3909
4451
|
case '/settings': {
|
|
3910
4452
|
const session = await readSession(request);
|
|
3911
4453
|
if (!session)
|
|
@@ -3929,14 +4471,20 @@ ${rows.length === 0
|
|
|
3929
4471
|
return await approvalsPage(session);
|
|
3930
4472
|
if (route === '/assistant')
|
|
3931
4473
|
return await assistantPage(session);
|
|
4474
|
+
if (route === '/settings/endpoints')
|
|
4475
|
+
return await settingsPage(session, 200, undefined, undefined, 'endpoints');
|
|
4476
|
+
if (route === '/settings/hours')
|
|
4477
|
+
return await settingsPage(session, 200, undefined, undefined, 'hours');
|
|
4478
|
+
if (route === '/settings/account')
|
|
4479
|
+
return await settingsPage(session, 200, undefined, undefined, 'account');
|
|
3932
4480
|
// A provider preset fills the assistant's form; nothing is saved until Save.
|
|
3933
4481
|
const preset = SLOTLOCK_ASSISTANT_PRESETS.find((candidate) => candidate.id === url.searchParams.get('preset'));
|
|
3934
4482
|
return await settingsPage(session, 200, undefined, preset ? { provider: preset.provider, baseUrl: preset.baseUrl } : undefined);
|
|
3935
4483
|
}
|
|
3936
4484
|
case '/style.css':
|
|
3937
|
-
return asset(
|
|
4485
|
+
return asset(DASHBOARD_ASSETS.css, 'text/css; charset=utf-8', url, ASSET_VERSIONS.css);
|
|
3938
4486
|
case '/app.js':
|
|
3939
|
-
return asset(
|
|
4487
|
+
return asset(DASHBOARD_ASSETS.js, 'text/javascript; charset=utf-8', url, ASSET_VERSIONS.js);
|
|
3940
4488
|
case '/favicon.svg':
|
|
3941
4489
|
return asset(SLOTLOCK_DASHBOARD_ICON_SVG, 'image/svg+xml', url, ASSET_VERSIONS.icon);
|
|
3942
4490
|
case '/sign-in':
|
|
@@ -4140,13 +4688,44 @@ a.stat:hover { background: var(--surface-2); text-decoration: none; }
|
|
|
4140
4688
|
a.stat::after { content: ""; position: absolute; top: 18px; right: 18px; width: 6px; height: 6px; border-top: 1.5px solid var(--ink-3); border-right: 1.5px solid var(--ink-3); transform: rotate(45deg); opacity: 0; transition: opacity var(--fast) var(--ease); }
|
|
4141
4689
|
a.stat:hover::after, a.stat:focus-visible::after { opacity: 1; }
|
|
4142
4690
|
a.stat:focus-visible { outline-offset: -2px; border-radius: 0; }
|
|
4143
|
-
.stat-label { font:
|
|
4691
|
+
.stat-label { font-size: var(--text-sm); line-height: 1.4; color: var(--ink-3); }
|
|
4692
|
+
.attention-stat .stat-value { color: var(--accent); }
|
|
4144
4693
|
.stat-value { font-size: var(--text-2xl); line-height: 1.1; font-weight: 500; letter-spacing: -0.03em; font-variant-numeric: tabular-nums; }
|
|
4145
4694
|
|
|
4146
|
-
/*
|
|
4147
|
-
.
|
|
4148
|
-
.
|
|
4149
|
-
.
|
|
4695
|
+
/* Overview: what needs a person, getting started, the side column */
|
|
4696
|
+
.column { display: grid; gap: var(--space-5); align-content: start; min-width: 0; }
|
|
4697
|
+
.panel-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-3); margin-bottom: var(--space-3); }
|
|
4698
|
+
.panel-head h2 { margin: 0; }
|
|
4699
|
+
.panel-head .meta { flex: none; }
|
|
4700
|
+
.attention-list { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-2); }
|
|
4701
|
+
.attention { display: flex; align-items: center; gap: var(--space-4); padding: 10px var(--space-4); border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); font-size: var(--text-md); color: var(--ink); box-shadow: inset 3px 0 0 var(--ink-3); }
|
|
4702
|
+
.attention.bad { border-color: var(--bad-line); background: var(--bad-wash); box-shadow: inset 3px 0 0 var(--bad); }
|
|
4703
|
+
.attention.warn { border-color: var(--warn-line); background: var(--warn-wash); box-shadow: inset 3px 0 0 var(--warn); }
|
|
4704
|
+
.attention.info { box-shadow: inset 3px 0 0 var(--accent); }
|
|
4705
|
+
.attention-text { flex: 1; min-width: 0; }
|
|
4706
|
+
.attention-action { flex: none; font-weight: 600; white-space: nowrap; }
|
|
4707
|
+
.segments { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: 4px; }
|
|
4708
|
+
.segments span { height: 4px; border-radius: var(--radius-full); background: var(--sunken); }
|
|
4709
|
+
.segments span.done { background: var(--good); }
|
|
4710
|
+
.setup-card .segments { margin-bottom: var(--space-3); }
|
|
4711
|
+
.setup-steps { list-style: none; margin: 0; padding: 0; display: grid; }
|
|
4712
|
+
.setup-steps li { display: flex; align-items: center; gap: var(--space-3); min-height: 44px; border-top: 1px solid var(--line-soft); color: var(--ink-3); }
|
|
4713
|
+
.setup-steps li:first-child { border-top: 0; }
|
|
4714
|
+
.setup-steps li.next { color: var(--ink); font-weight: 500; }
|
|
4715
|
+
.setup-steps li.done .step-title { text-decoration: line-through; text-decoration-color: var(--line-strong); }
|
|
4716
|
+
.setup-steps .step-title { flex: 1; min-width: 0; }
|
|
4717
|
+
.setup-row { display: flex; align-items: center; gap: var(--space-4); padding: 10px var(--space-4); border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--surface); color: var(--ink-2); font-size: var(--text-md); }
|
|
4718
|
+
.setup-row:hover { background: var(--surface-2); text-decoration: none; }
|
|
4719
|
+
.setup-row .segments { width: 96px; flex: none; }
|
|
4720
|
+
.plan-card .panel-head h2 { font-size: var(--text-lg); }
|
|
4721
|
+
.meters.stacked { grid-template-columns: minmax(0, 1fr); gap: var(--space-4); margin: 0 0 var(--space-4); }
|
|
4722
|
+
.plan-card > .button { width: 100%; justify-content: center; }
|
|
4723
|
+
details.command { margin-top: var(--space-4); }
|
|
4724
|
+
details.command > summary { cursor: pointer; font-size: var(--text-md); color: var(--ink-2); }
|
|
4725
|
+
details.command[open] > summary { margin-bottom: var(--space-2); }
|
|
4726
|
+
.copy-block { position: relative; }
|
|
4727
|
+
.copy-block pre { padding-right: 72px; font-size: var(--text-sm); }
|
|
4728
|
+
.copy-block button { position: absolute; top: 6px; right: 6px; }
|
|
4150
4729
|
.check { display: inline-grid; place-items: center; width: 20px; height: 20px; flex: none; border-radius: var(--radius-full); border: 1.5px dashed var(--line-strong); color: var(--surface); }
|
|
4151
4730
|
.done .check { background: var(--good); border: 0; }
|
|
4152
4731
|
.check .icon { width: 12px; height: 12px; stroke-width: 3; }
|
|
@@ -4164,7 +4743,11 @@ a.stat:focus-visible { outline-offset: -2px; border-radius: 0; }
|
|
|
4164
4743
|
.on { color: var(--ink-3); font-weight: 400; }
|
|
4165
4744
|
.meta { font-size: var(--text-sm); color: var(--ink-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
4166
4745
|
.feed { position: relative; }
|
|
4167
|
-
.event { position: relative; display: grid; grid-template-columns: 12px minmax(0, 1fr); gap: var(--space-3); align-items: start; padding: 7px 0; }
|
|
4746
|
+
.event { position: relative; display: grid; grid-template-columns: 12px minmax(0, 1fr) auto; gap: var(--space-3); align-items: start; padding: 7px 0; }
|
|
4747
|
+
.event .title { font-weight: 400; color: var(--ink-2); white-space: normal; overflow-wrap: anywhere; }
|
|
4748
|
+
.event .title strong { font-weight: 600; color: var(--ink); }
|
|
4749
|
+
.event .when { font-size: var(--text-sm); font-weight: 400; color: var(--ink-3); white-space: nowrap; padding-top: 1px; }
|
|
4750
|
+
.times { font-size: var(--text-sm); color: var(--ink-3); }
|
|
4168
4751
|
.event:not(:last-child)::before { content: ""; position: absolute; left: 5.5px; top: 22px; bottom: -8px; width: 1px; background: var(--line); }
|
|
4169
4752
|
.dot { position: relative; width: 12px; height: 12px; margin-top: 4px; border-radius: var(--radius-full); background: var(--surface); box-shadow: inset 0 0 0 3px var(--ink-3); }
|
|
4170
4753
|
.dot.good { box-shadow: inset 0 0 0 3px var(--good); }
|
|
@@ -4204,8 +4787,6 @@ textarea { padding: 10px var(--space-3); resize: vertical; min-height: 72px; lin
|
|
|
4204
4787
|
.create .grow { flex: 1 1 220px; }
|
|
4205
4788
|
label > input, label > select, label > .unit-input { width: 100%; }
|
|
4206
4789
|
label > textarea { width: 100%; padding: var(--space-2) var(--space-3); resize: vertical; }
|
|
4207
|
-
.presets { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); margin: 0 0 var(--space-3); }
|
|
4208
|
-
.presets button { height: 28px; padding: 0 var(--space-3); font-size: var(--text-sm); }
|
|
4209
4790
|
label.choice > input { width: 16px; }
|
|
4210
4791
|
.create label:not(.grow) { flex: 0 1 auto; }
|
|
4211
4792
|
form > label { margin-bottom: var(--space-3); }
|
|
@@ -4295,6 +4876,22 @@ form > .table { margin-bottom: var(--space-3); }
|
|
|
4295
4876
|
/* Notices and empty states */
|
|
4296
4877
|
.notice { display: block; border: 1px solid var(--line); border-radius: var(--radius); padding: var(--space-3) var(--space-4); background: var(--surface); color: var(--ink); margin: 0; font-size: var(--text-md); }
|
|
4297
4878
|
.notice[role="alert"] { border-color: var(--bad-line); background: var(--bad-wash); color: var(--ink); box-shadow: inset 3px 0 0 var(--bad); }
|
|
4879
|
+
.meters { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: var(--space-5) var(--space-6); margin: var(--space-4) 0; }
|
|
4880
|
+
.meter { display: grid; gap: 6px; min-width: 0; }
|
|
4881
|
+
.meter-head { display: flex; justify-content: space-between; align-items: baseline; gap: var(--space-2); font-size: var(--text-md); color: var(--ink-2); }
|
|
4882
|
+
.meter-value { color: var(--ink); font-weight: 500; font-variant-numeric: tabular-nums; }
|
|
4883
|
+
.meter progress { appearance: none; -webkit-appearance: none; width: 100%; height: 6px; border: 0; border-radius: var(--radius-full); background: var(--sunken); overflow: hidden; }
|
|
4884
|
+
.meter progress::-webkit-progress-bar { background: var(--sunken); border-radius: var(--radius-full); }
|
|
4885
|
+
.meter progress::-webkit-progress-value { background: var(--ink-2); border-radius: var(--radius-full); }
|
|
4886
|
+
.meter progress::-moz-progress-bar { background: var(--ink-2); border-radius: var(--radius-full); }
|
|
4887
|
+
.meter.warn progress::-webkit-progress-value { background: var(--warn); }
|
|
4888
|
+
.meter.warn progress::-moz-progress-bar { background: var(--warn); }
|
|
4889
|
+
.meter.bad progress::-webkit-progress-value { background: var(--bad); }
|
|
4890
|
+
.meter.bad progress::-moz-progress-bar { background: var(--bad); }
|
|
4891
|
+
.meter.bad .meter-value { color: var(--bad); }
|
|
4892
|
+
.plan-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-3); }
|
|
4893
|
+
.plan-head h2 { margin: 0; }
|
|
4894
|
+
.notice.done { border-color: color-mix(in srgb, var(--good) 35%, var(--line)); background: var(--good-wash); color: var(--ink); box-shadow: inset 3px 0 0 var(--good); }
|
|
4298
4895
|
.notice.warn, .notice.warn[role="alert"] { border-color: var(--warn-line); background: var(--warn-wash); color: var(--warn); box-shadow: inset 3px 0 0 var(--warn); }
|
|
4299
4896
|
.notice p { color: inherit; margin: 0 0 var(--space-2); }
|
|
4300
4897
|
.empty { color: var(--ink-3); margin: 0; }
|
|
@@ -4331,23 +4928,6 @@ pre { background: var(--surface-2); border: 1px solid var(--line); border-radius
|
|
|
4331
4928
|
.bare-main > .notice { max-width: 480px; }
|
|
4332
4929
|
|
|
4333
4930
|
/* Assistant */
|
|
4334
|
-
.chat { display: grid; gap: var(--space-5); }
|
|
4335
|
-
.thread { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-3); }
|
|
4336
|
-
.msg { max-width: 78%; padding: 10px 14px; border-radius: 14px; background: var(--surface-2); border: 1px solid var(--line); }
|
|
4337
|
-
.msg p { margin: 0; color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
4338
|
-
.msg.mine { justify-self: end; background: var(--button); border-color: var(--button); border-bottom-right-radius: 4px; }
|
|
4339
|
-
.msg.mine p { color: var(--button-ink); }
|
|
4340
|
-
.msg.bot { justify-self: start; border-bottom-left-radius: 4px; }
|
|
4341
|
-
.tool-line { font: 400 var(--text-sm)/1.5 var(--mono); color: var(--ink-3); padding-left: var(--space-1); }
|
|
4342
|
-
.thread > .notice { justify-self: start; }
|
|
4343
|
-
.action-card { justify-self: start; width: min(100%, 540px); padding: var(--space-4) var(--space-5); border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--pop-shadow); display: grid; gap: var(--space-3); }
|
|
4344
|
-
.action-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); }
|
|
4345
|
-
.action-card .facts { font-size: var(--text-md); gap: 6px var(--space-4); }
|
|
4346
|
-
.action-card .facts dt::first-letter { text-transform: uppercase; }
|
|
4347
|
-
.action-card pre.wrap { white-space: pre-wrap; margin: 0; padding: var(--space-2); font-size: 12px; }
|
|
4348
|
-
.action-summary { margin: 0; color: var(--ink); font-weight: 500; overflow-wrap: anywhere; }
|
|
4349
|
-
.action-card .action-buttons { margin-top: var(--space-1); }
|
|
4350
|
-
.composer { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--space-3); align-items: end; margin: 0; padding-top: var(--space-4); border-top: 1px solid var(--line-soft); }
|
|
4351
4931
|
|
|
4352
4932
|
@media (max-width: 1080px) {
|
|
4353
4933
|
.split { grid-template-columns: minmax(0, 1fr); }
|
|
@@ -4377,6 +4957,8 @@ pre { background: var(--surface-2); border: 1px solid var(--line); border-radius
|
|
|
4377
4957
|
.stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
4378
4958
|
.stat { padding: 14px var(--space-4); box-shadow: -1px 0 0 var(--line), 0 -1px 0 var(--line); }
|
|
4379
4959
|
.stat-value { font-size: 22px; }
|
|
4960
|
+
.attention { flex-wrap: wrap; gap: var(--space-1) var(--space-4); }
|
|
4961
|
+
.setup-row .segments { width: 56px; }
|
|
4380
4962
|
.create > * { flex: 1 1 100%; }
|
|
4381
4963
|
.create button { width: 100%; }
|
|
4382
4964
|
.endpoints { grid-template-columns: minmax(0, 1fr); gap: var(--space-1); }
|
|
@@ -4405,8 +4987,6 @@ pre { background: var(--surface-2); border: 1px solid var(--line); border-radius
|
|
|
4405
4987
|
.act.danger summary { border-color: var(--bad-line); }
|
|
4406
4988
|
.pop { left: 0; right: auto; width: min(300px, calc(100vw - 48px)); transform-origin: top left; }
|
|
4407
4989
|
.table .pop { margin-left: 0; }
|
|
4408
|
-
.msg { max-width: 92%; }
|
|
4409
|
-
.composer { grid-template-columns: minmax(0, 1fr); }
|
|
4410
4990
|
.empty-state { padding: var(--space-8) var(--space-4); }
|
|
4411
4991
|
.bare-main { padding-top: 6vh; }
|
|
4412
4992
|
.sign-in { padding: var(--space-6); }
|
|
@@ -4425,10 +5005,15 @@ const COMPOSITE_PROBLEMS = Object.freeze({
|
|
|
4425
5005
|
composite_parts_limit: 'A whole has at most 50 parts.',
|
|
4426
5006
|
composite_busy: 'One of them has a booking or hold still to come. A composite changes only while neither does.',
|
|
4427
5007
|
});
|
|
4428
|
-
/** What the
|
|
5008
|
+
/** What the Usage page calls each plan. */
|
|
4429
5009
|
const PLAN_NAMES = Object.freeze({ free: 'Free', developer: 'Developer', team: 'Team' });
|
|
5010
|
+
const DASHBOARD_ASSETS = Object.freeze({
|
|
5011
|
+
css: DASHBOARD_CSS + SLOTLOCK_ASSISTANT_CSS + SLOTLOCK_SETTINGS_CSS,
|
|
5012
|
+
// The chat's script renders a reply with the server's own Markdown renderer, embedded.
|
|
5013
|
+
js: DASHBOARD_JS + slotlockAssistantScript(renderSlotlockMarkdown.toString()),
|
|
5014
|
+
});
|
|
4430
5015
|
const ASSET_VERSIONS = Object.freeze({
|
|
4431
|
-
css: createHash('sha256').update(
|
|
4432
|
-
js: createHash('sha256').update(
|
|
5016
|
+
css: createHash('sha256').update(DASHBOARD_ASSETS.css).digest('base64url').slice(0, 12),
|
|
5017
|
+
js: createHash('sha256').update(DASHBOARD_ASSETS.js).digest('base64url').slice(0, 12),
|
|
4433
5018
|
icon: createHash('sha256').update(SLOTLOCK_DASHBOARD_ICON_SVG).digest('base64url').slice(0, 12),
|
|
4434
5019
|
});
|