slotlock 0.7.0 → 0.9.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 +281 -0
- package/agent-server.d.ts +25 -5
- package/agent-server.js +152 -7
- package/agent-store-backend.d.ts +6 -1
- package/agent-store-backend.js +861 -55
- package/application-api.d.ts +19 -1
- package/application-api.js +578 -21
- package/approvals.d.ts +2 -0
- package/approvals.js +12 -5
- package/assistant.d.ts +64 -9
- package/assistant.js +450 -82
- package/availability-store.d.ts +193 -0
- package/availability-store.js +558 -0
- package/booking-rules.d.ts +92 -5
- package/booking-rules.js +243 -15
- package/dashboard-assistant-ui.d.ts +21 -0
- package/dashboard-assistant-ui.js +539 -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 +84 -11
- package/dashboard.js +1654 -384
- package/ddl.d.ts +5 -1
- package/ddl.js +760 -9
- package/event-feed.d.ts +1 -1
- package/event-feed.js +18 -0
- package/holiday-import.d.ts +38 -0
- package/holiday-import.js +57 -0
- package/holidays.d.ts +38 -0
- package/holidays.js +150 -0
- package/hours.d.ts +125 -0
- package/hours.js +224 -0
- package/index.d.ts +14 -3
- package/index.js +10 -2
- package/interop.d.ts +25 -7
- package/interop.js +145 -17
- package/lifecycle.d.ts +31 -0
- package/lifecycle.js +115 -0
- package/maintenance.d.ts +19 -0
- package/maintenance.js +55 -1
- package/metrics.js +3 -0
- package/openapi.json +16237 -7795
- package/package.json +1 -1
- package/ranges.d.ts +28 -0
- package/ranges.js +135 -0
- package/resource-search.d.ts +19 -0
- package/resource-search.js +153 -0
- package/rest-api.js +7 -0
- package/rules.d.ts +2 -0
- package/rules.js +2 -0
- package/self-host.d.ts +1 -1
- package/self-host.js +221 -21
- package/slotlock.manifest.json +1 -1
- package/store.d.ts +367 -24
- package/store.js +1567 -97
- package/types.d.ts +198 -0
package/dashboard.js
CHANGED
|
@@ -16,26 +16,36 @@
|
|
|
16
16
|
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
17
17
|
import { SLOTLOCK_TEST_TENANT_SUFFIX, } from './api-keys.js';
|
|
18
18
|
import { describeSlotlockAgentWrite, invokeSlotlockAgentOperation, slotlockAgentTools, } from './agent-server.js';
|
|
19
|
-
import { SLOTLOCK_ASSISTANT_API_KEY, SLOTLOCK_ASSISTANT_MAX_ACTION_BYTES, SLOTLOCK_ASSISTANT_MAX_MESSAGE, SLOTLOCK_ASSISTANT_MAX_PENDING, SLOTLOCK_ASSISTANT_MAX_PREVIOUS_SECRETS, SLOTLOCK_ASSISTANT_MODEL_NAME, SLOTLOCK_ASSISTANT_PRESETS, SLOTLOCK_ASSISTANT_TOOL_USE, boundSlotlockAssistantEntries, createSlotlockAssistantStore, openSlotlockAssistantKeyWithAny, pendingSlotlockAssistantActions, pruneSlotlockAssistantActions, checkSlotlockAssistantHost, readSlotlockAssistantBaseUrl, slotlockAssistantUrlAllowed, readSlotlockAssistantEntries, runSlotlockAssistantTurn, sealSlotlockAssistantKey, slotlockAssistantProviderMessage, slotlockAssistantSystemPrompt, testSlotlockAssistantModel, listSlotlockAssistantModels, } from './assistant.js';
|
|
19
|
+
import { SLOTLOCK_ASSISTANT_API_KEY, SLOTLOCK_ASSISTANT_MAX_ACTION_BYTES, SLOTLOCK_ASSISTANT_MAX_MESSAGE, SLOTLOCK_ASSISTANT_MAX_PENDING, SLOTLOCK_ASSISTANT_MAX_PREVIOUS_SECRETS, SLOTLOCK_ASSISTANT_MODEL_NAME, SLOTLOCK_ASSISTANT_PRESETS, SLOTLOCK_ASSISTANT_TOOL_USE, boundSlotlockAssistantEntries, createSlotlockAssistantStore, openSlotlockAssistantKeyWithAny, pendingSlotlockAssistantActions, pruneSlotlockAssistantActions, checkSlotlockAssistantHost, readSlotlockAssistantBaseUrl, slotlockAssistantUrlAllowed, readSlotlockAssistantEntries, runSlotlockAssistantTurn, sealSlotlockAssistantKey, slotlockAssistantProviderMessage, SLOTLOCK_ASSISTANT_DIRECTORY_SIZE, slotlockAssistantSystemPrompt, testSlotlockAssistantModel, listSlotlockAssistantModels, } from './assistant.js';
|
|
20
20
|
import { SLOTLOCK_DASHBOARD_NAME_PATTERN } from './config.js';
|
|
21
21
|
import { createSlotlockRateLimiter } from './rate-limit.js';
|
|
22
22
|
import { createSlotlockGuardedFetch } from './webhooks.js';
|
|
23
23
|
import { lookup as dnsLookup } from 'node:dns/promises';
|
|
24
|
-
import { SLOTLOCK_DASHBOARD_FORM_LIMIT } from './ddl.js';
|
|
24
|
+
import { SLOTLOCK_ASSISTANT_CHAT_ID, 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';
|
|
31
34
|
import { slotlockUsageMonthToDate, summarizeSlotlockUsage } from './usage.js';
|
|
32
35
|
import { SLOTLOCK_CHANGE_TYPES, } from './event-feed.js';
|
|
36
|
+
import { dayToDate } from './hours.js';
|
|
37
|
+
import { SLOTLOCK_MAX_HOLIDAY_CALENDAR_BYTES } from './holidays.js';
|
|
38
|
+
import { readSlotlockHolidayCalendar } from './holiday-import.js';
|
|
33
39
|
import { SlotlockAgentOperationError } from './agent-server.js';
|
|
34
40
|
import { runSlotlockApprovalGrant } from './agent-store-backend.js';
|
|
35
41
|
import { SLOTLOCK_SANDBOX_SCENARIOS, fastForwardSlotlockSandbox, resetSlotlockSandbox, seedSlotlockSandbox, } from './sandbox.js';
|
|
36
42
|
import { WEEKDAYS, WEEKDAY_NAMES as DAY_NAMES, rulesToWeeklyHours, weeklyHoursToRules, } from './weekly-hours.js';
|
|
37
43
|
/** Resources one dashboard tenant may hold. */
|
|
38
44
|
export const SLOTLOCK_DASHBOARD_MAX_RESOURCES = 500;
|
|
45
|
+
/** Closures the Closures page shows. */
|
|
46
|
+
const SLOTLOCK_DASHBOARD_MAX_EXCEPTIONS = 200;
|
|
47
|
+
/** Contacts the Contacts page shows at once. */
|
|
48
|
+
const SLOTLOCK_DASHBOARD_CONTACTS_PAGE = 100;
|
|
39
49
|
const SESSION_TTL_MS = 12 * 3_600_000;
|
|
40
50
|
const SIGN_IN_TTL_MS = 10 * 60_000;
|
|
41
51
|
/**
|
|
@@ -48,6 +58,8 @@ const GOOGLE_KEYS_DEFAULT_TTL_MS = 3_600_000;
|
|
|
48
58
|
const MAX_FORM_BYTES = 8_192;
|
|
49
59
|
/** A message to the assistant: 2,000 characters, each up to 9 bytes as a form encodes it. */
|
|
50
60
|
const MAX_ASSISTANT_FORM_BYTES = 24_576;
|
|
61
|
+
/** A holiday calendar import's form: the calendar (SLOTLOCK_MAX_HOLIDAY_CALENDAR_BYTES) and its fields. */
|
|
62
|
+
const MAX_IMPORT_FORM_BYTES = SLOTLOCK_MAX_HOLIDAY_CALENDAR_BYTES + 65_536;
|
|
51
63
|
/**
|
|
52
64
|
* Sign-ins (record, then GitHub's token and user) one instance runs at once; more are told to retry.
|
|
53
65
|
* This also bounds how fast unauthenticated callbacks can write sign-in records.
|
|
@@ -84,14 +96,22 @@ const POST_ROUTES = new Set([
|
|
|
84
96
|
'/sandbox/reset',
|
|
85
97
|
'/sandbox/fast-forward',
|
|
86
98
|
'/hours',
|
|
99
|
+
'/closures',
|
|
100
|
+
'/closures/delete',
|
|
101
|
+
'/closures/import',
|
|
102
|
+
'/closures/calendars/delete',
|
|
103
|
+
'/contacts/forget',
|
|
87
104
|
'/organization',
|
|
88
105
|
'/environment',
|
|
89
106
|
'/teams/enable',
|
|
90
107
|
'/teams/disable',
|
|
91
108
|
'/assistant',
|
|
109
|
+
'/assistant/stream',
|
|
92
110
|
'/assistant/approve',
|
|
93
111
|
'/assistant/dismiss',
|
|
94
112
|
'/assistant/reset',
|
|
113
|
+
'/assistant/rename',
|
|
114
|
+
'/assistant/delete',
|
|
95
115
|
'/settings/assistant',
|
|
96
116
|
'/settings/assistant/test',
|
|
97
117
|
'/settings/assistant/models',
|
|
@@ -132,7 +152,7 @@ const KEY_EXPIRY_DAYS = Object.freeze({
|
|
|
132
152
|
* still sends no referrer to any other site, so the callback URL's code never leaves.
|
|
133
153
|
*/
|
|
134
154
|
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'";
|
|
155
|
+
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
156
|
/** The policy, with billing's origins allowed as form targets so Upgrade and Manage can redirect. */
|
|
137
157
|
function policyFor(billing) {
|
|
138
158
|
if (!billing)
|
|
@@ -217,12 +237,49 @@ export function createSlotlockDashboardResources(store, options = {}) {
|
|
|
217
237
|
timezone,
|
|
218
238
|
maxTenantResources: SLOTLOCK_DASHBOARD_MAX_RESOURCES,
|
|
219
239
|
}), { isolation: 'read committed', ...by(actor) }),
|
|
220
|
-
setHours: (tenantRef, target, rules, actor) => store.withTenant(tenantRef, async (tenant) => {
|
|
221
|
-
if (target === 'all')
|
|
222
|
-
return tenant.
|
|
240
|
+
setHours: (tenantRef, target, rules, actor, override) => store.withTenant(tenantRef, async (tenant) => {
|
|
241
|
+
if (target === 'all') {
|
|
242
|
+
return (await tenant.storeTenantAvailability({ tenantRef, rules, overrideResources: override ?? 'none' })).inheriting;
|
|
243
|
+
}
|
|
244
|
+
if ('location' in target) {
|
|
245
|
+
const result = await tenant.setLocationAvailability({ tenantRef, location: target.location, rules });
|
|
246
|
+
return result === null ? 0 : Math.max(1, result.inheriting);
|
|
247
|
+
}
|
|
223
248
|
const updated = await tenant.setResourceAvailability({ tenantRef, id: target.id, rules });
|
|
224
249
|
return updated ? 1 : 0;
|
|
225
250
|
}, by(actor)),
|
|
251
|
+
contacts: {
|
|
252
|
+
list: (tenantRef, limit, after) => store.withTenant(tenantRef, (tenant) => tenant.listContacts({ tenantRef, limit, ...(after ? { after } : {}) })),
|
|
253
|
+
forget: (tenantRef, ref, actor) => store.withTenant(tenantRef, (tenant) => tenant.deleteContact({ tenantRef, ref }), { isolation: 'read committed', ...by(actor) }),
|
|
254
|
+
},
|
|
255
|
+
hours: {
|
|
256
|
+
defaults: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.getAvailabilityDefaults({ tenantRef })),
|
|
257
|
+
exceptions: (tenantRef, limit) => store.withTenant(tenantRef, (tenant) => tenant.listAvailabilityExceptions({
|
|
258
|
+
tenantRef,
|
|
259
|
+
from: dayToDate(Math.floor(Date.now() / 86_400_000) - 1),
|
|
260
|
+
limit,
|
|
261
|
+
})),
|
|
262
|
+
addException: (tenantRef, exception, actor) => store.withTenant(tenantRef, (tenant) => tenant.createAvailabilityException({ ...exception, tenantRef }), by(actor)),
|
|
263
|
+
deleteException: (tenantRef, id, actor) => store.withTenant(tenantRef, (tenant) => tenant.deleteAvailabilityException({ tenantRef, id }), by(actor)),
|
|
264
|
+
importHolidays: async (tenantRef, source, scope, actor) => {
|
|
265
|
+
// Read (a URL fetched) before the transaction opens.
|
|
266
|
+
const read = await readSlotlockHolidayCalendar(source, options.holidayFetch);
|
|
267
|
+
return store.withTenant(tenantRef, async (tenant) => {
|
|
268
|
+
const result = await tenant.importHolidayCalendar({
|
|
269
|
+
tenantRef,
|
|
270
|
+
...scope,
|
|
271
|
+
name: read.name,
|
|
272
|
+
...(read.url !== undefined ? { url: read.url } : {}),
|
|
273
|
+
holidays: read.holidays,
|
|
274
|
+
span: read.span,
|
|
275
|
+
});
|
|
276
|
+
return { imported: result.imported, skipped: read.skipped, removed: result.removed };
|
|
277
|
+
}, by(actor));
|
|
278
|
+
},
|
|
279
|
+
byUrl: options.holidayFetch !== undefined,
|
|
280
|
+
calendars: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.listHolidayCalendars({ tenantRef })),
|
|
281
|
+
deleteCalendar: (tenantRef, id, actor) => store.withTenant(tenantRef, async (tenant) => (await tenant.deleteHolidayCalendar({ tenantRef, id })) !== null, by(actor)),
|
|
282
|
+
},
|
|
226
283
|
agenda: (tenantRef, window, limit) => store.withTenant(tenantRef, (tenant) => tenant.listAgenda({ tenantRef, window, limit })),
|
|
227
284
|
recentChanges: (tenantRef, limit) => store.withTenant(tenantRef, (tenant) => tenant.listRecentChanges({ tenantRef, limit })),
|
|
228
285
|
setRules: (tenantRef, id, rules, actor) => store.withTenant(tenantRef, (tenant) => tenant.setResourceBookingRules({ tenantRef, id, rules }), by(actor)),
|
|
@@ -459,6 +516,11 @@ const RULE_NAMES = Object.freeze({
|
|
|
459
516
|
bufferBeforeMinutes: 'Buffer before',
|
|
460
517
|
turnaroundMinutes: 'Turnaround',
|
|
461
518
|
checkInMinutes: 'Check-in release',
|
|
519
|
+
minDurationMinutes: 'Shortest booking',
|
|
520
|
+
maxDurationMinutes: 'Longest booking',
|
|
521
|
+
defaultDurationMinutes: 'Usual length',
|
|
522
|
+
cancelCutoffMinutes: 'Cancel cutoff',
|
|
523
|
+
rescheduleCutoffMinutes: 'Move cutoff',
|
|
462
524
|
});
|
|
463
525
|
/** The booking rules form's fields, by rule. */
|
|
464
526
|
const RULE_FIELDS = Object.freeze({
|
|
@@ -469,6 +531,11 @@ const RULE_FIELDS = Object.freeze({
|
|
|
469
531
|
bufferBeforeMinutes: 'buffer_before_minutes',
|
|
470
532
|
turnaroundMinutes: 'turnaround_minutes',
|
|
471
533
|
checkInMinutes: 'check_in_minutes',
|
|
534
|
+
minDurationMinutes: 'min_duration_minutes',
|
|
535
|
+
maxDurationMinutes: 'max_duration_minutes',
|
|
536
|
+
defaultDurationMinutes: 'default_duration_minutes',
|
|
537
|
+
cancelCutoffMinutes: 'cancel_cutoff_minutes',
|
|
538
|
+
rescheduleCutoffMinutes: 'reschedule_cutoff_minutes',
|
|
472
539
|
});
|
|
473
540
|
/** How the inherited-rules note names each rule, with its unit. */
|
|
474
541
|
const INHERITED_NAMES = Object.freeze({
|
|
@@ -476,7 +543,28 @@ const INHERITED_NAMES = Object.freeze({
|
|
|
476
543
|
maxAdvanceDays: 'Book up to (days ahead)',
|
|
477
544
|
enforceHours: 'Only inside bookable hours',
|
|
478
545
|
requireCertain: 'Only in time its calendar syncs have read today',
|
|
546
|
+
allowedDurations: 'Lengths (min)',
|
|
547
|
+
pickupTimes: 'Pick-up times',
|
|
548
|
+
returnTimes: 'Return times',
|
|
549
|
+
cancellationFee: 'Cancellation fee',
|
|
550
|
+
hoursApply: 'Hours hold a hire at',
|
|
479
551
|
});
|
|
552
|
+
/** A rule's value as the dashboard shows it. */
|
|
553
|
+
function ruleValueText(value) {
|
|
554
|
+
if (typeof value === 'number')
|
|
555
|
+
return value.toLocaleString('en');
|
|
556
|
+
if (typeof value === 'boolean')
|
|
557
|
+
return value ? 'on' : 'off';
|
|
558
|
+
if (typeof value === 'string')
|
|
559
|
+
return value === 'ends' ? 'its start and end' : 'all of it';
|
|
560
|
+
if (!Array.isArray(value)) {
|
|
561
|
+
const fee = value;
|
|
562
|
+
return `${(fee.amountMinor / 100).toFixed(2)} ${fee.currency}${fee.withinMinutes !== undefined ? ` within ${fee.withinMinutes.toLocaleString('en')} min` : ''}`;
|
|
563
|
+
}
|
|
564
|
+
return value
|
|
565
|
+
.map((item) => (typeof item === 'number' ? item.toLocaleString('en') : hoursOf(item.startMinutes, item.durationMinutes)))
|
|
566
|
+
.join(', ');
|
|
567
|
+
}
|
|
480
568
|
/**
|
|
481
569
|
* The rules a resource takes from defaults, each with where it comes from: a rule its pool's
|
|
482
570
|
* defaults set, else one the tenant's set, where its own rules do not. Without the defaults, the
|
|
@@ -504,7 +592,7 @@ function inheritedNote(inherited) {
|
|
|
504
592
|
return html ``;
|
|
505
593
|
const parts = [...inherited].map(([name, { value, from }]) => typeof value === 'boolean'
|
|
506
594
|
? `${INHERITED_NAMES[name]}${value ? '' : ': off'} (${from})`
|
|
507
|
-
: `${INHERITED_NAMES[name]} ${value
|
|
595
|
+
: `${INHERITED_NAMES[name]} ${ruleValueText(value)} (${from})`);
|
|
508
596
|
return html `<p class="fine">Inherited: ${parts.join('; ')}.</p>`;
|
|
509
597
|
}
|
|
510
598
|
const icon = (paths) => new Html(`<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${paths}</svg>`);
|
|
@@ -660,6 +748,20 @@ const CHANGE_LABELS = Object.freeze({
|
|
|
660
748
|
'approval.expired': { text: 'Approval not decided in time', tone: 'bad' },
|
|
661
749
|
'webhook.disabled': { text: 'Webhook disabled', tone: 'bad' },
|
|
662
750
|
'usage.threshold_reached': { text: 'Monthly quota nearly or fully used', tone: 'bad' },
|
|
751
|
+
'usage.period_closed': { text: 'Month of use closed', tone: 'neutral' },
|
|
752
|
+
'resource.created': { text: 'Resource added', tone: 'good' },
|
|
753
|
+
'resource.updated': { text: 'Resource changed', tone: 'neutral' },
|
|
754
|
+
'resource.archived': { text: 'Resource archived', tone: 'neutral' },
|
|
755
|
+
'resource.restored': { text: 'Resource restored', tone: 'good' },
|
|
756
|
+
'resource.deleted': { text: 'Resource deleted', tone: 'bad' },
|
|
757
|
+
'key.created': { text: 'API key created', tone: 'good' },
|
|
758
|
+
'key.rotated': { text: 'API key rotated', tone: 'neutral' },
|
|
759
|
+
'key.revoked': { text: 'API key revoked', tone: 'bad' },
|
|
760
|
+
'webhook.created': { text: 'Webhook added', tone: 'good' },
|
|
761
|
+
'webhook.enabled': { text: 'Webhook enabled', tone: 'good' },
|
|
762
|
+
'webhook.deleted': { text: 'Webhook deleted', tone: 'neutral' },
|
|
763
|
+
'coverage.recorded': { text: 'Calendar synced', tone: 'neutral' },
|
|
764
|
+
'coverage.deleted': { text: 'Calendar sync removed', tone: 'neutral' },
|
|
663
765
|
});
|
|
664
766
|
const zoned = (zone, parts) => {
|
|
665
767
|
try {
|
|
@@ -765,6 +867,10 @@ function formatDate(date) {
|
|
|
765
867
|
return '—';
|
|
766
868
|
return `${date.toISOString().slice(0, 16).replace('T', ' ')} UTC`;
|
|
767
869
|
}
|
|
870
|
+
/** A UTC day as people write it: `9 Dec 2026`. */
|
|
871
|
+
function dayOf(date) {
|
|
872
|
+
return zoned('UTC', { day: 'numeric', month: 'short', year: 'numeric' }).format(date);
|
|
873
|
+
}
|
|
768
874
|
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
769
875
|
/** A table's date: the day (`19 Oct 2026`), with the full UTC time on hover and for machines. */
|
|
770
876
|
function dateCell(date) {
|
|
@@ -894,6 +1000,25 @@ function weekFromForm(form) {
|
|
|
894
1000
|
}
|
|
895
1001
|
return week;
|
|
896
1002
|
}
|
|
1003
|
+
/** Minutes after midnight for `HH:MM`, or null. */
|
|
1004
|
+
function minutesOf(value) {
|
|
1005
|
+
const match = TIME.exec(value);
|
|
1006
|
+
return match ? Number(match[1]) * 60 + Number(match[2]) : null;
|
|
1007
|
+
}
|
|
1008
|
+
/**
|
|
1009
|
+
* A resource's hours in one line: its own, or what it inherits and from where (RFC 0004 B2), or the
|
|
1010
|
+
* server's.
|
|
1011
|
+
*/
|
|
1012
|
+
function resourceHours(resource, server) {
|
|
1013
|
+
if (resource.availabilityRules !== undefined)
|
|
1014
|
+
return hoursSummary(resource.availabilityRules);
|
|
1015
|
+
const inherited = resource.inheritedAvailability;
|
|
1016
|
+
if (inherited) {
|
|
1017
|
+
const from = inherited.source === 'location' ? `${inherited.location ?? 'Location'}'s hours` : 'Your hours';
|
|
1018
|
+
return `${from}: ${hoursSummary(inherited.rules)}`;
|
|
1019
|
+
}
|
|
1020
|
+
return server.length === 0 ? "Server's hours (none)" : `Server's hours: ${hoursSummary(server)}`;
|
|
1021
|
+
}
|
|
897
1022
|
export function createSlotlockDashboard(options) {
|
|
898
1023
|
const publicUrl = new URL(options.publicUrl);
|
|
899
1024
|
const origin = publicUrl.origin;
|
|
@@ -920,6 +1045,20 @@ export function createSlotlockDashboard(options) {
|
|
|
920
1045
|
}
|
|
921
1046
|
// Teams on Cloud: organizations turned on in the database, beside every person's own tenant.
|
|
922
1047
|
const teams = options.organizations === 'enabled' ? (options.teams ?? null) : null;
|
|
1048
|
+
// Plans whether or not billing sells them; billing's own reader when only it is given.
|
|
1049
|
+
const plans = options.plans ??
|
|
1050
|
+
(options.billing
|
|
1051
|
+
? {
|
|
1052
|
+
plan: options.billing.plan,
|
|
1053
|
+
...(options.billing.enrol ? { enrol: options.billing.enrol } : {}),
|
|
1054
|
+
contact: options.billing.contact,
|
|
1055
|
+
}
|
|
1056
|
+
: undefined);
|
|
1057
|
+
if (plans?.contact) {
|
|
1058
|
+
const protocol = new URL(plans.contact).protocol;
|
|
1059
|
+
if (protocol !== 'https:' && protocol !== 'mailto:')
|
|
1060
|
+
throw new Error('plans.contact must be an https or mailto URL');
|
|
1061
|
+
}
|
|
923
1062
|
if (options.organizations === 'enabled' && !teams) {
|
|
924
1063
|
throw new Error('Slotlock dashboard organizations "enabled" needs the teams store');
|
|
925
1064
|
}
|
|
@@ -1106,9 +1245,7 @@ export function createSlotlockDashboard(options) {
|
|
|
1106
1245
|
...(assistantStore()
|
|
1107
1246
|
? [{ section: 'assistant', label: 'Assistant', path: '/assistant', icon: ICONS.assistant }]
|
|
1108
1247
|
: []),
|
|
1109
|
-
...(
|
|
1110
|
-
? [{ section: 'plan', label: 'Plan', path: '/plan', icon: ICONS.plan }]
|
|
1111
|
-
: []),
|
|
1248
|
+
...(plans ? [{ section: 'plan', label: 'Usage', path: '/plan', icon: ICONS.plan }] : []),
|
|
1112
1249
|
...(teams ? [{ section: 'teams', label: 'Teams', path: '/teams', icon: ICONS.teams }] : []),
|
|
1113
1250
|
{ section: 'settings', label: 'Settings', path: '/settings', icon: ICONS.settings },
|
|
1114
1251
|
];
|
|
@@ -1177,13 +1314,19 @@ ${live}
|
|
|
1177
1314
|
</body>
|
|
1178
1315
|
</html>`;
|
|
1179
1316
|
}
|
|
1180
|
-
|
|
1317
|
+
// A notice is an alert only when the page answers with an error: a done or saved message is a
|
|
1318
|
+
// status, so success is never painted in the error colour.
|
|
1319
|
+
const noticeOf = (notice, status = 200) => notice
|
|
1320
|
+
? status >= 400
|
|
1321
|
+
? html `<p class="notice" role="alert">${notice}</p>`
|
|
1322
|
+
: html `<p class="notice done" role="status">${notice}</p>`
|
|
1323
|
+
: html ``;
|
|
1181
1324
|
function signedOutPage(status, notice, cookies = []) {
|
|
1182
1325
|
const content = html `<section class="sign-in">
|
|
1183
1326
|
<span class="sign-in-mark">${ICONS.mark}</span>
|
|
1184
1327
|
<h1>Sign in to ${brandName}</h1>
|
|
1185
1328
|
<p>Create keys for your agents, add what they book, and watch what they do.</p>
|
|
1186
|
-
${noticeOf(notice)}
|
|
1329
|
+
${noticeOf(notice, status)}
|
|
1187
1330
|
<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
1331
|
${options.termsUrl
|
|
1189
1332
|
? html `<p class="fine">By continuing you agree to the <a href="${options.termsUrl}">Terms of Service</a>.${options.privacyUrl
|
|
@@ -1273,74 +1416,209 @@ ${options.termsUrl
|
|
|
1273
1416
|
}
|
|
1274
1417
|
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
1418
|
}
|
|
1419
|
+
/**
|
|
1420
|
+
* What each change says in the feed, as a sentence: who did it (`verb` then the resource), or what
|
|
1421
|
+
* happened (`passive`, the resource in place of {X}). Every change type the feed records has one,
|
|
1422
|
+
* so the feed never shows a raw type such as resource.updated.
|
|
1423
|
+
*/
|
|
1424
|
+
const ACTIVITY = Object.freeze({
|
|
1425
|
+
'hold.created': { verb: 'held' },
|
|
1426
|
+
'hold.updated': { verb: 'changed the hold on' },
|
|
1427
|
+
'hold.confirmed': { verb: 'booked', after: ' from a hold' },
|
|
1428
|
+
'hold.released': { verb: 'released the hold on' },
|
|
1429
|
+
'hold.expired': { passive: 'The hold on {X} expired' },
|
|
1430
|
+
'reservation.created': { verb: 'booked' },
|
|
1431
|
+
'reservation.updated': { verb: 'moved a booking on' },
|
|
1432
|
+
'reservation.cancelled': { verb: 'cancelled a booking on' },
|
|
1433
|
+
'reservation.checked_in': { verb: 'checked in to' },
|
|
1434
|
+
'reservation.no_show': { passive: '{X} was released after a no-show' },
|
|
1435
|
+
'event.created': { verb: 'added an event on' },
|
|
1436
|
+
'event.updated': { verb: 'changed an event on' },
|
|
1437
|
+
'event.cancelled': { verb: 'cancelled an event on' },
|
|
1438
|
+
'event.timezone_shifted': { passive: 'An event on {X} moved with a time zone change' },
|
|
1439
|
+
'event.timezone_conflict': { passive: 'An event on {X} clashes after a time zone change' },
|
|
1440
|
+
'coverage.stale': { passive: 'The calendar sync for {X} is behind' },
|
|
1441
|
+
'coverage.recorded': { passive: 'The calendar for {X} synced' },
|
|
1442
|
+
'coverage.deleted': { verb: 'removed the calendar sync for' },
|
|
1443
|
+
'waitlist.offered': { passive: 'A waitlist offer went out for {X}' },
|
|
1444
|
+
'waitlist.expired': { passive: 'A waitlist offer for {X} lapsed' },
|
|
1445
|
+
'approval.requested': { passive: 'A booking on {X} waits for approval' },
|
|
1446
|
+
'approval.granted': { verb: 'approved a booking on' },
|
|
1447
|
+
'approval.declined': { verb: 'declined a booking on' },
|
|
1448
|
+
'approval.expired': { passive: 'A booking on {X} was not approved in time' },
|
|
1449
|
+
'resource.created': { verb: 'added' },
|
|
1450
|
+
'resource.updated': { verb: 'edited' },
|
|
1451
|
+
'resource.archived': { verb: 'archived' },
|
|
1452
|
+
'resource.restored': { verb: 'restored' },
|
|
1453
|
+
'resource.deleted': { verb: 'deleted' },
|
|
1454
|
+
'key.created': { verb: 'created an API key' },
|
|
1455
|
+
'key.rotated': { verb: 'rotated an API key' },
|
|
1456
|
+
'key.revoked': { verb: 'revoked an API key' },
|
|
1457
|
+
'webhook.created': { verb: 'added a webhook' },
|
|
1458
|
+
'webhook.enabled': { verb: 'turned a webhook back on' },
|
|
1459
|
+
'webhook.disabled': { passive: 'A webhook was turned off' },
|
|
1460
|
+
'webhook.deleted': { verb: 'deleted a webhook' },
|
|
1461
|
+
'usage.period_closed': { passive: 'A month of use closed' },
|
|
1462
|
+
});
|
|
1463
|
+
/** Changes about a resource, which name it; the rest stand alone. */
|
|
1464
|
+
const ABOUT_A_RESOURCE = /^(hold|reservation|event|coverage|waitlist|approval|resource)\./;
|
|
1276
1465
|
function activityPanel(changes, byId, keys, session) {
|
|
1277
1466
|
if (changes.length === 0) {
|
|
1278
1467
|
return html `<p class="empty">No activity yet. What agents book, move and cancel shows here.</p>`;
|
|
1279
1468
|
}
|
|
1280
1469
|
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
|
-
|
|
1470
|
+
const whoOf = (actor) => actor === null
|
|
1471
|
+
? brandName
|
|
1472
|
+
: actor === personOf(session)
|
|
1473
|
+
? 'You'
|
|
1474
|
+
: (keyNames.get(actor) ??
|
|
1475
|
+
(actor.startsWith('api_key:')
|
|
1476
|
+
? 'A deleted key'
|
|
1477
|
+
: /^github:\d+$/.test(actor)
|
|
1478
|
+
? `GitHub user ${actor.slice(7)}`
|
|
1479
|
+
: /^google:/.test(actor)
|
|
1480
|
+
? 'A Google user'
|
|
1481
|
+
: /^dashboard-assistant:(github|google):/.test(actor)
|
|
1482
|
+
? actor === assistantActor(session)
|
|
1483
|
+
? `Assistant for ${shownAs(session)}`
|
|
1484
|
+
: actor.startsWith('dashboard-assistant:google:')
|
|
1485
|
+
? 'Assistant for a Google user'
|
|
1486
|
+
: `Assistant for GitHub user ${actor.slice(27)}`
|
|
1487
|
+
: actor.slice(0, 60)));
|
|
1488
|
+
// Repeats in a row (one actor, one change, one resource) read as one line with a count.
|
|
1489
|
+
const runs = [];
|
|
1490
|
+
for (const change of changes) {
|
|
1491
|
+
const last = runs.at(-1);
|
|
1492
|
+
if (last && last.change.actor === change.actor && last.change.type === change.type && last.change.resourceId === change.resourceId) {
|
|
1493
|
+
last.times += 1;
|
|
1494
|
+
}
|
|
1495
|
+
else {
|
|
1496
|
+
runs.push({ change, times: 1 });
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
// Own entries only: a type such as `constructor` names nothing here.
|
|
1500
|
+
const own = (map, type) => (Object.hasOwn(map, type) ? map[type] : undefined);
|
|
1501
|
+
return html `<ol class="feed">${runs.map(({ change, times }) => {
|
|
1502
|
+
const tone = own(CHANGE_LABELS, change.type)?.tone ?? 'neutral';
|
|
1503
|
+
const where = change.resourceId ? resourceName(byId.get(change.resourceId), change.resourceId) : null;
|
|
1504
|
+
const object = where ?? (ABOUT_A_RESOURCE.test(change.type) ? 'a resource' : null);
|
|
1505
|
+
const words = own(ACTIVITY, change.type);
|
|
1506
|
+
let sentence;
|
|
1507
|
+
if (change.type === 'usage.threshold_reached') {
|
|
1508
|
+
const metric = typeof change.data.metric === 'string' ? change.data.metric : 'use';
|
|
1509
|
+
const percent = typeof change.data.percent === 'number' ? change.data.percent : null;
|
|
1510
|
+
sentence = html `${percent === null ? 'A monthly quota' : `${String(percent)}% of this month's ${metric}`} used`;
|
|
1511
|
+
}
|
|
1512
|
+
else if (words?.verb) {
|
|
1513
|
+
sentence = html `<strong>${whoOf(change.actor)}</strong> ${words.verb}${object ? html ` <strong>${object}</strong>` : html ``}${words.after ?? ''}`;
|
|
1514
|
+
}
|
|
1515
|
+
else if (words?.passive) {
|
|
1516
|
+
const [before, rest] = words.passive.split('{X}');
|
|
1517
|
+
sentence = rest === undefined ? html `${before}` : html `${before}<strong>${object ?? 'a resource'}</strong>${rest}`;
|
|
1518
|
+
}
|
|
1519
|
+
else {
|
|
1520
|
+
// A change this dashboard has no words for (a newer server's): its label, never its type.
|
|
1521
|
+
const label = own(CHANGE_LABELS, change.type)?.text;
|
|
1522
|
+
sentence = label
|
|
1523
|
+
? html `${label}${object ? html ` <span class="on">on</span> <strong>${object}</strong>` : html ``}`
|
|
1524
|
+
: html `Something changed${where ? html ` on <strong>${where}</strong>` : html ``}`;
|
|
1525
|
+
}
|
|
1526
|
+
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
1527
|
})}</ol>`;
|
|
1306
1528
|
}
|
|
1529
|
+
/** The MCP and A2A endpoints with Copy, and the command that adds Slotlock to an MCP client. */
|
|
1530
|
+
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
1531
|
async function overviewPage(session, status = 200, notice) {
|
|
1308
1532
|
const tenantRef = tenantOf(session);
|
|
1309
1533
|
const start = new Date(now());
|
|
1310
1534
|
const window = { start, end: new Date(start.getTime() + AGENDA_DAYS * DAY_MS) };
|
|
1311
|
-
const
|
|
1535
|
+
const assistant = assistantStore();
|
|
1536
|
+
const [keys, resources, agenda, changes, approvals, waitlist, model] = await Promise.all([
|
|
1312
1537
|
options.keys.list({ tenantRef }),
|
|
1313
1538
|
options.resources.list(tenantRef),
|
|
1314
1539
|
options.resources.agenda?.(tenantRef, window, AGENDA_LIMIT),
|
|
1315
1540
|
options.resources.recentChanges?.(tenantRef, ACTIVITY_LIMIT),
|
|
1541
|
+
options.resources.approvals?.list(tenantRef, APPROVALS_SHOWN),
|
|
1542
|
+
options.resources.waitlist?.list(tenantRef, WAITLIST_SHOWN),
|
|
1543
|
+
assistant?.getSettings(tenantRef),
|
|
1316
1544
|
]);
|
|
1545
|
+
// The plan is Live's: the test environment has none of its own.
|
|
1546
|
+
const standing = plans && session.env !== 'test' ? await planStanding(session, 'overview', keys) : null;
|
|
1317
1547
|
const byId = new Map(resources.map((resource) => [resource.id, resource]));
|
|
1318
1548
|
const active = keys.filter((key) => !key.revokedAt && (key.expiresAt === null || key.expiresAt.getTime() > now()));
|
|
1319
1549
|
const counted = (count) => agenda && agenda.length >= AGENDA_LIMIT ? `${count}+` : String(count);
|
|
1320
1550
|
const booked = agenda?.filter((entry) => entry.kind !== 'hold').length ?? 0;
|
|
1321
1551
|
const held = agenda?.filter((entry) => entry.kind === 'hold').length ?? 0;
|
|
1322
|
-
const
|
|
1552
|
+
const waiting = approvals?.filter((approval) => approval.status === 'pending').length ?? 0;
|
|
1553
|
+
const attention = [
|
|
1554
|
+
...(standing?.attention ?? []),
|
|
1555
|
+
...(waiting > 0
|
|
1556
|
+
? [
|
|
1557
|
+
attentionItem('info', `${String(waiting)} ${waiting === 1 ? 'booking waits' : 'bookings wait'} for your approval.`, {
|
|
1558
|
+
href: `${root}/approvals`,
|
|
1559
|
+
label: 'Review',
|
|
1560
|
+
}),
|
|
1561
|
+
]
|
|
1562
|
+
: []),
|
|
1563
|
+
];
|
|
1564
|
+
const admin = isAdmin(session);
|
|
1323
1565
|
const steps = [
|
|
1324
|
-
{ done: resources.length > 0,
|
|
1325
|
-
{ done: active.length > 0,
|
|
1326
|
-
|
|
1566
|
+
{ done: resources.length > 0, title: 'Add a resource', action: { href: `${root}/resources`, label: 'Add resource' } },
|
|
1567
|
+
{ done: active.length > 0, title: 'Create an API key', action: { href: `${root}/keys#new-key`, label: 'Create key' } },
|
|
1568
|
+
// Done once an agent has used a key.
|
|
1569
|
+
{ done: active.some((key) => key.lastUsedAt !== null), title: 'Connect an agent', action: { href: `${root}#connect`, label: 'Connect' } },
|
|
1570
|
+
...(assistant
|
|
1571
|
+
? [
|
|
1572
|
+
admin
|
|
1573
|
+
? { done: Boolean(model), title: 'Connect a model to the assistant', action: { href: `${root}/settings`, label: 'Connect' } }
|
|
1574
|
+
: { done: Boolean(model), title: 'Try the assistant', action: { href: `${root}/assistant`, label: 'Open' } },
|
|
1575
|
+
]
|
|
1576
|
+
: []),
|
|
1327
1577
|
];
|
|
1328
|
-
const
|
|
1329
|
-
|
|
1330
|
-
|
|
1578
|
+
const done = steps.filter((step) => step.done).length;
|
|
1579
|
+
const next = steps.findIndex((step) => !step.done);
|
|
1580
|
+
// Nothing made and nothing happened yet: only the way in.
|
|
1581
|
+
const fresh = resources.length === 0 && active.length === 0 && (changes?.length ?? 0) === 0;
|
|
1582
|
+
const setupCard = next === -1
|
|
1331
1583
|
? 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
|
-
|
|
1584
|
+
: 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>`;
|
|
1585
|
+
// Mostly set up: one line that leads to the next step.
|
|
1586
|
+
const setupRow = next === -1
|
|
1587
|
+
? html ``
|
|
1588
|
+
: 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>`;
|
|
1589
|
+
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>`;
|
|
1590
|
+
const tiles = [
|
|
1591
|
+
...(agenda ? [stat(`Bookings, next ${AGENDA_DAYS} days`, counted(booked), `${root}#upcoming`), stat('Live holds', counted(held), `${root}#upcoming`)] : []),
|
|
1592
|
+
...(approvals ? [stat('Waiting for approval', String(waiting), `${root}/approvals`, waiting > 0 ? ' attention-stat' : '')] : []),
|
|
1593
|
+
...(waitlist ? [stat('On the waitlist', String(waitlist.filter((entry) => entry.status !== 'expired').length), `${root}/waitlist`)] : []),
|
|
1594
|
+
stat('Resources', String(resources.length), `${root}/resources`),
|
|
1595
|
+
stat('Active keys', String(active.length), `${root}/keys`),
|
|
1596
|
+
].slice(0, 4);
|
|
1597
|
+
const planCard = standing
|
|
1598
|
+
? 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
|
|
1599
|
+
? html `<a class="button primary" href="${root}/plan#upgrade">Upgrade</a>`
|
|
1600
|
+
: html ``}</section>`
|
|
1601
|
+
: html ``;
|
|
1602
|
+
const connectCard = html `<section class="panel" id="connect"><h2>Connect an agent</h2>${connectAgent()}</section>`;
|
|
1603
|
+
const body = fresh
|
|
1604
|
+
? standing
|
|
1605
|
+
? html `<div class="split">${setupCard}<div class="column">${planCard}</div></div>`
|
|
1606
|
+
: setupCard
|
|
1607
|
+
: html `${done * 2 >= steps.length ? setupRow : setupCard}
|
|
1608
|
+
<div class="stats">${tiles}</div>
|
|
1339
1609
|
${session.env === 'test' && options.resources.sandbox ? sandboxPanel(session, resources.length) : html ``}
|
|
1340
1610
|
<div class="split">
|
|
1611
|
+
<div class="column">
|
|
1341
1612
|
${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>
|
|
1613
|
+
${changes ? html `<section class="panel"><h2>Activity</h2>${activityPanel(changes, byId, keys, session)}</section>` : html ``}
|
|
1614
|
+
</div>
|
|
1615
|
+
<div class="column">${planCard}${connectCard}</div>
|
|
1343
1616
|
</div>`;
|
|
1617
|
+
const content = html `${noticeOf(notice, status)}
|
|
1618
|
+
${pageHead('Overview')}
|
|
1619
|
+
${attentionList(attention)}
|
|
1620
|
+
${fresh && session.env === 'test' && options.resources.sandbox ? sandboxPanel(session, resources.length) : html ``}
|
|
1621
|
+
${body}`;
|
|
1344
1622
|
return respond(status, layout('Overview', content, session, 'overview'));
|
|
1345
1623
|
}
|
|
1346
1624
|
/** In Test: load a scenario to try an agent against, move waiting forward, or start again (RFC 0004 A6). */
|
|
@@ -1404,7 +1682,7 @@ ${resourceCount === 0
|
|
|
1404
1682
|
const limitChoices = (locations?.length ?? 0) + (groups?.length ?? 0) > 0
|
|
1405
1683
|
? 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
1684
|
: html ``;
|
|
1407
|
-
const content = html `${noticeOf(notice)}
|
|
1685
|
+
const content = html `${noticeOf(notice, status)}
|
|
1408
1686
|
${pageHead('API keys')}
|
|
1409
1687
|
<section class="panel">
|
|
1410
1688
|
<h2>Connect an agent</h2>
|
|
@@ -1435,7 +1713,7 @@ ${keys.length === 0
|
|
|
1435
1713
|
* dashboard's own limit, or its plan's when lower (archived resources count against a plan).
|
|
1436
1714
|
*/
|
|
1437
1715
|
async function resourceAllowance(tenantRef, resources) {
|
|
1438
|
-
const plan =
|
|
1716
|
+
const plan = plans ? await plans.plan(tenantRef) : null;
|
|
1439
1717
|
if (!plan || plan.limits.resources >= SLOTLOCK_DASHBOARD_MAX_RESOURCES) {
|
|
1440
1718
|
return { used: resources.length, limit: SLOTLOCK_DASHBOARD_MAX_RESOURCES, planned: false, archived: 0 };
|
|
1441
1719
|
}
|
|
@@ -1447,12 +1725,16 @@ ${keys.length === 0
|
|
|
1447
1725
|
archived: Math.max(0, counted - resources.length),
|
|
1448
1726
|
};
|
|
1449
1727
|
}
|
|
1450
|
-
async function resourcesPage(session, status = 200, notice) {
|
|
1451
|
-
const
|
|
1452
|
-
const allowance = await resourceAllowance(tenantOf(session),
|
|
1728
|
+
async function resourcesPage(session, status = 200, notice, ownHoursOnly = false) {
|
|
1729
|
+
const every = await options.resources.list(tenantOf(session));
|
|
1730
|
+
const allowance = await resourceAllowance(tenantOf(session), every);
|
|
1731
|
+
// `?hours=own`: those that keep hours of their own, which the tenant's hours do not reach.
|
|
1732
|
+
const resources = ownHoursOnly
|
|
1733
|
+
? every.filter((resource) => resource.archivedAt === undefined && resource.availabilityRules !== undefined)
|
|
1734
|
+
: every;
|
|
1453
1735
|
const token = csrfToken(session);
|
|
1454
|
-
const content = html `${noticeOf(notice)}
|
|
1455
|
-
${pageHead('Resources', html `<div class="row-actions">${options.resources.grouping ? html `<a class="button" href="${root}/locations">Locations and groups</a>` : html ``}${resources.
|
|
1736
|
+
const content = html `${noticeOf(notice, status)}
|
|
1737
|
+
${pageHead('Resources', html `<div class="row-actions">${options.resources.grouping ? html `<a class="button" href="${root}/locations">Locations and groups</a>` : html ``}<a class="button" href="${root}/hours">Your hours</a>${options.resources.hours ? html `<a class="button" href="${root}/closures">Closures</a>` : html ``}${options.resources.contacts ? html `<a class="button" href="${root}/contacts">Contacts</a>` : html ``}</div>`)}
|
|
1456
1738
|
<section class="panel" id="add-resource">
|
|
1457
1739
|
<h2>Add a resource</h2>
|
|
1458
1740
|
<form class="create" method="post" action="${root}/resources">
|
|
@@ -1464,9 +1746,11 @@ ${pageHead('Resources', html `<div class="row-actions">${options.resources.group
|
|
|
1464
1746
|
</form>
|
|
1465
1747
|
<p class="fine">${String(allowance.used)} of ${String(allowance.limit)}${allowance.planned ? html ` on your plan${allowance.archived > 0 ? html `, ${String(allowance.archived)} of them archived (archived resources count until deleted)` : html ``}` : html ``}. Adding a reference you have changes its time zone.</p>
|
|
1466
1748
|
</section>
|
|
1467
|
-
${
|
|
1749
|
+
${ownHoursOnly
|
|
1750
|
+
? html `<p class="notice" role="status">${String(resources.length)} ${resources.length === 1 ? 'resource keeps hours of its own' : 'resources keep hours of their own'}, so <a href="${root}/hours">your hours</a> do not reach ${resources.length === 1 ? 'it' : 'them'}. <a href="${root}/resources">Show every resource</a></p>`
|
|
1751
|
+
: html ``}${resources.length === 0 && !ownHoursOnly
|
|
1468
1752
|
? emptyState(ICONS.resources, 'No resources yet. Agents can only book what is listed here.', { href: '#reference', label: 'Add a resource' })
|
|
1469
|
-
: html `<div class="table"><table><thead><tr><th>Reference</th><th>Time zone</th><th>Bookable hours</th><th>Resource id</th></tr></thead><tbody>${resources.map((resource) => html `<tr><td data-label="Reference" class="strong"><span class="cell"><a class="truncate" href="${root}/resources/${resource.id}" title="${resourceName(resource, resource.id)}">${resourceName(resource, resource.id)}</a>${resource.name && resource.externalRef ? html `<span class="meta" title="Reference ${resource.externalRef}">${resource.externalRef}</span>` : html ``}${resource.pool ? html `<span class="meta" title="Pool ${resource.pool}">Pool ${resource.pool}</span>` : html ``}</span></td><td data-label="Time zone">${resource.timezone}</td><td data-label="Bookable hours"><span class="cell"><span>${
|
|
1753
|
+
: html `<div class="table"><table><thead><tr><th>Reference</th><th>Time zone</th><th>Bookable hours</th><th>Resource id</th></tr></thead><tbody>${resources.map((resource) => html `<tr><td data-label="Reference" class="strong"><span class="cell"><a class="truncate" href="${root}/resources/${resource.id}" title="${resourceName(resource, resource.id)}">${resourceName(resource, resource.id)}</a>${resource.name && resource.externalRef ? html `<span class="meta" title="Reference ${resource.externalRef}">${resource.externalRef}</span>` : html ``}${resource.pool ? html `<span class="meta" title="Pool ${resource.pool}">Pool ${resource.pool}</span>` : html ``}</span></td><td data-label="Time zone">${resource.timezone}</td><td data-label="Bookable hours"><span class="cell"><span>${resourceHours(resource, options.availability ?? [])}</span><a class="meta-link" href="${root}/hours?resource=${resource.id}">Set hours</a></span></td><td data-label="Resource id"><code class="muted truncate id" title="${resource.id}">${resource.id}</code></td></tr>`)}</tbody></table></div>`}`;
|
|
1470
1754
|
return respond(status, layout('Resources', content, session, 'resources'));
|
|
1471
1755
|
}
|
|
1472
1756
|
/** The tenant's locations and groups (RFC 0003 G2, G3): what they are, and adding and changing them. */
|
|
@@ -1492,7 +1776,7 @@ ${resources.length === 0
|
|
|
1492
1776
|
<td data-label="Location" class="strong"><span class="cell"><span class="truncate" title="${paths.get(location.ref) ?? location.name}">${location.name}</span><span class="meta">${location.ref}${parent ? ` · in ${parent}` : ''}</span></span></td>
|
|
1493
1777
|
<td data-label="Time zone">${zone ? (zone.from === location ? zone.timezone : html `<span class="cell"><span>${zone.timezone}</span><span class="meta">from ${zone.from.name}</span></span>`) : html `<span class="muted">None</span>`}</td>
|
|
1494
1778
|
<td data-label="Resources">${String(location.resources)}</td>
|
|
1495
|
-
<td class="actions"
|
|
1779
|
+
<td class="actions">${options.resources.hours ? html `<a class="button" href="${root}/hours?location=${location.ref}">Hours</a>` : html ``}<details class="act"><summary>Edit</summary><div class="pop"><form method="post" action="${root}/locations">${sent()}<input type="hidden" name="ref" value="${location.ref}">
|
|
1496
1780
|
<label>Name<input name="name" required maxlength="${nameLimit}" value="${location.name}"></label>
|
|
1497
1781
|
<label>Inside<select name="parent">${insideChoices(location.parentRef, location.ref)}</select></label>
|
|
1498
1782
|
<label>Time zone<input name="timezone" maxlength="100" value="${location.timezone ?? ''}" placeholder="As where it is"></label>
|
|
@@ -1510,7 +1794,7 @@ ${resources.length === 0
|
|
|
1510
1794
|
<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
1795
|
<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
1796
|
</tr>`);
|
|
1513
|
-
const content = html `${noticeOf(notice)}
|
|
1797
|
+
const content = html `${noticeOf(notice, status)}
|
|
1514
1798
|
<p><a class="back" href="${root}/resources">Resources</a></p>
|
|
1515
1799
|
${pageHead('Locations and groups')}
|
|
1516
1800
|
<section class="panel" id="locations">
|
|
@@ -1542,32 +1826,46 @@ ${groups.length === 0
|
|
|
1542
1826
|
</section>`;
|
|
1543
1827
|
return respond(status, layout('Locations and groups', content, session, 'resources'));
|
|
1544
1828
|
}
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1829
|
+
/** Resources with hours of their own the tenant's hours page names; the rest are counted. */
|
|
1830
|
+
const OWN_HOURS_NAMED = 8;
|
|
1831
|
+
/** Settings' sections, each its own page: the assistant's form apart from what is only read. */
|
|
1832
|
+
const SETTINGS_SECTIONS = Object.freeze([
|
|
1833
|
+
{ id: 'assistant', label: 'Assistant', path: '/settings' },
|
|
1834
|
+
{ id: 'endpoints', label: 'Endpoints', path: '/settings/endpoints' },
|
|
1835
|
+
{ id: 'hours', label: 'Hours', path: '/settings/hours' },
|
|
1836
|
+
{ id: 'account', label: 'Account', path: '/settings/account' },
|
|
1837
|
+
]);
|
|
1838
|
+
async function settingsPage(session, status = 200, notice, draft, section = 'assistant') {
|
|
1839
|
+
const store = assistantStore();
|
|
1840
|
+
const sections = SETTINGS_SECTIONS.filter((entry) => entry.id !== 'assistant' || store);
|
|
1841
|
+
const shown = section === 'assistant' && !store ? 'endpoints' : section;
|
|
1842
|
+
const connected = store ? Boolean(await store.getSettings(tenantOf(session))) : false;
|
|
1843
|
+
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>`;
|
|
1844
|
+
let body;
|
|
1845
|
+
if (shown === 'assistant') {
|
|
1846
|
+
body = await assistantSettingsSection(session, status, notice, draft);
|
|
1847
|
+
}
|
|
1848
|
+
else if (shown === 'endpoints') {
|
|
1849
|
+
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>`;
|
|
1850
|
+
}
|
|
1851
|
+
else if (shown === 'hours') {
|
|
1852
|
+
const rules = options.availability ?? [];
|
|
1853
|
+
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
|
|
1854
|
+
? "None. Agents find free time only on resources with hours of their own, their location's or yours."
|
|
1855
|
+
: 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 with no hours of their own, their location's or <a href="${root}/hours">yours</a>. Set by the server's operator.</p>`;
|
|
1856
|
+
}
|
|
1857
|
+
else {
|
|
1858
|
+
const current = session.orgs?.find((org) => org.id === session.org);
|
|
1859
|
+
const others = (session.orgs ?? []).filter((org) => org.id !== current?.id);
|
|
1860
|
+
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
|
|
1861
|
+
? 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>`
|
|
1862
|
+
: 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 ``}`;
|
|
1863
|
+
}
|
|
1864
|
+
const content = html `${pageHead('Settings')}<div class="settings">${nav}<div class="settings-body">${body}</div></div>`;
|
|
1569
1865
|
return respond(status, layout('Settings', content, session, 'settings'));
|
|
1570
1866
|
}
|
|
1867
|
+
/** A value to copy: monospace, cut with an ellipsis, with a Copy button (app.js). */
|
|
1868
|
+
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
1869
|
/** The tenant's resource with that id and every resource (for names), or null for anything else. */
|
|
1572
1870
|
async function resourceDetailPage(session, id, status = 200, notice) {
|
|
1573
1871
|
const tenantRef = tenantOf(session);
|
|
@@ -1612,11 +1910,11 @@ ${current ? html `<p class="fine">Membership is read from GitHub when you sign i
|
|
|
1612
1910
|
const fallback = from ? `As its default (${from.value ? 'on' : 'off'}, ${from.from})` : 'Off';
|
|
1613
1911
|
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
1912
|
};
|
|
1615
|
-
const content = html `${noticeOf(notice)}
|
|
1913
|
+
const content = html `${noticeOf(notice, status)}
|
|
1616
1914
|
<p><a class="back" href="${root}/resources">Resources</a></p>
|
|
1617
1915
|
${pageHead(resourceName(resource, resource.id))}
|
|
1618
1916
|
${resource.description ? html `<p>${resource.description}</p>` : html ``}
|
|
1619
|
-
<dl class="facts">${resource.name && resource.externalRef ? html `<dt>Reference</dt><dd>${resource.externalRef}</dd>` : html ``}${resource.kind ? html `<dt>Kind</dt><dd>${KIND_NAMES[resource.kind]}</dd>` : html ``}${resource.capacity !== undefined ? html `<dt>Capacity</dt><dd>${String(resource.capacity)}</dd>` : html ``}${resource.labels ? html `<dt>Labels</dt><dd>${resource.labels.join(', ')}</dd>` : html ``}${resource.location ? html `<dt>Location</dt><dd>${paths.get(resource.location) ?? resource.locationName ?? resource.location}</dd>` : html ``}${resource.groups ? html `<dt>Groups</dt><dd>${resource.groups.map((ref) => groupNames.get(ref) ?? ref).join(', ')}</dd>` : html ``}<dt>Time zone</dt><dd>${resource.timezone}</dd><dt>Bookable hours</dt><dd>${
|
|
1917
|
+
<dl class="facts">${resource.name && resource.externalRef ? html `<dt>Reference</dt><dd>${resource.externalRef}</dd>` : html ``}${resource.kind ? html `<dt>Kind</dt><dd>${KIND_NAMES[resource.kind]}</dd>` : html ``}${resource.capacity !== undefined ? html `<dt>Capacity</dt><dd>${String(resource.capacity)}</dd>` : html ``}${resource.labels ? html `<dt>Labels</dt><dd>${resource.labels.join(', ')}</dd>` : html ``}${resource.location ? html `<dt>Location</dt><dd>${paths.get(resource.location) ?? resource.locationName ?? resource.location}</dd>` : html ``}${resource.groups ? html `<dt>Groups</dt><dd>${resource.groups.map((ref) => groupNames.get(ref) ?? ref).join(', ')}</dd>` : html ``}<dt>Time zone</dt><dd>${resource.timezone}</dd><dt>Bookable hours</dt><dd>${resourceHours(resource, options.availability ?? [])} <a href="${root}/hours?resource=${resource.id}">Set hours</a></dd><dt>Resource id</dt><dd><code class="muted">${resource.id}</code></dd></dl>
|
|
1620
1918
|
<div class="split">
|
|
1621
1919
|
<div class="stack">
|
|
1622
1920
|
${options.resources.setRules
|
|
@@ -1634,7 +1932,18 @@ ${field('stepOffsetMinutes', 'Steps start past each step by', 'min')}
|
|
|
1634
1932
|
${field('bufferBeforeMinutes', 'Kept free before each booking', 'min')}
|
|
1635
1933
|
${field('turnaroundMinutes', 'Turnaround after each booking', 'min')}
|
|
1636
1934
|
${field('checkInMinutes', 'Release if not checked in after', 'min')}
|
|
1935
|
+
${field('minDurationMinutes', 'Shortest booking', 'min')}
|
|
1936
|
+
${field('maxDurationMinutes', 'Longest booking', 'min')}
|
|
1937
|
+
${field('defaultDurationMinutes', 'Usual length, offered when none is asked', 'min')}
|
|
1938
|
+
${field('cancelCutoffMinutes', 'Cancel up to, before the start', 'min')}
|
|
1939
|
+
${field('rescheduleCutoffMinutes', 'Move up to, before the start', 'min')}
|
|
1637
1940
|
</div>
|
|
1941
|
+
${resource.bookingRules?.allowedDurations || resource.bookingRules?.pickupTimes || resource.bookingRules?.returnTimes || resource.bookingRules?.cancellationFee ? html `<p class="fine">Set through the API, and kept when you save: ${[
|
|
1942
|
+
resource.bookingRules.allowedDurations ? `lengths ${ruleValueText(resource.bookingRules.allowedDurations)} min` : '',
|
|
1943
|
+
resource.bookingRules.pickupTimes ? `pick-up ${ruleValueText(resource.bookingRules.pickupTimes)}` : '',
|
|
1944
|
+
resource.bookingRules.returnTimes ? `return ${ruleValueText(resource.bookingRules.returnTimes)}` : '',
|
|
1945
|
+
resource.bookingRules.cancellationFee ? `cancellation fee ${ruleValueText(resource.bookingRules.cancellationFee)}` : '',
|
|
1946
|
+
].filter(Boolean).join('; ')}.</p>` : html ``}
|
|
1638
1947
|
${toggle('enforceHours', 'enforce_hours', 'Only inside bookable hours')}
|
|
1639
1948
|
${toggle('requireCertain', 'require_certain', 'Only in time its calendar syncs have read today')}
|
|
1640
1949
|
${inheritedNote(inherited)}
|
|
@@ -1743,6 +2052,12 @@ ${activity ? html `<section class="panel"><h2>Activity</h2>${activityPanel(activ
|
|
|
1743
2052
|
const requireCertain = choice('require_certain');
|
|
1744
2053
|
if (requireCertain !== undefined)
|
|
1745
2054
|
rules.requireCertain = requireCertain;
|
|
2055
|
+
// Rules the form does not edit (set through the API) are kept.
|
|
2056
|
+
for (const name of ['allowedDurations', 'pickupTimes', 'returnTimes', 'cancellationFee']) {
|
|
2057
|
+
const kept = resource.bookingRules?.[name];
|
|
2058
|
+
if (kept !== undefined)
|
|
2059
|
+
rules[name] = kept;
|
|
2060
|
+
}
|
|
1746
2061
|
try {
|
|
1747
2062
|
await options.resources.setRules(tenantOf(session), id, Object.keys(rules).length === 0 ? null : rules, personOf(session));
|
|
1748
2063
|
}
|
|
@@ -1960,7 +2275,7 @@ ${activity ? html `<section class="panel"><h2>Activity</h2>${activityPanel(activ
|
|
|
1960
2275
|
<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
2276
|
</tr>`;
|
|
1962
2277
|
});
|
|
1963
|
-
const content = html `${noticeOf(notice)}
|
|
2278
|
+
const content = html `${noticeOf(notice, status)}
|
|
1964
2279
|
${pageHead('Webhooks')}
|
|
1965
2280
|
<div class="stats">
|
|
1966
2281
|
<div class="stat"><span class="stat-label">Endpoints</span><span class="stat-value">${String(endpoints.length)}</span></div>
|
|
@@ -1991,7 +2306,7 @@ ${endpoints.length === 0
|
|
|
1991
2306
|
const created = await hooks.create(tenantOf(session), (form.get('url') ?? '').trim(), types.length === 0 ? undefined : types, personOf(session));
|
|
1992
2307
|
if (!created.ok) {
|
|
1993
2308
|
const message = created.code === 'webhook_limit_reached'
|
|
1994
|
-
? `You have ${String(created.limit)} webhooks, as many as ${
|
|
2309
|
+
? `You have ${String(created.limit)} webhooks, as many as ${plans ? 'your plan' : 'Slotlock'} allows. Delete one first${plans ? ', or see Usage' : ''}.`
|
|
1995
2310
|
: created.code === 'invalid_webhook_url'
|
|
1996
2311
|
? 'Give an https URL, with no user name, password or #fragment.'
|
|
1997
2312
|
: 'Choose events from the list.';
|
|
@@ -2060,7 +2375,7 @@ ${endpoints.length === 0
|
|
|
2060
2375
|
});
|
|
2061
2376
|
const waiting = entries.filter((entry) => entry.status === 'waiting').length;
|
|
2062
2377
|
const offered = entries.filter((entry) => entry.status === 'offered').length;
|
|
2063
|
-
const content = html `${noticeOf(notice)}
|
|
2378
|
+
const content = html `${noticeOf(notice, status)}
|
|
2064
2379
|
${pageHead('Waitlist')}
|
|
2065
2380
|
<div class="stats">
|
|
2066
2381
|
<div class="stat"><span class="stat-label">Waiting</span><span class="stat-value">${String(waiting)}</span></div>
|
|
@@ -2126,7 +2441,7 @@ ${entries.length === 0
|
|
|
2126
2441
|
<td data-label="Decided by"><code class="truncate" title="${entry.decidedBy ?? ''}">${entry.decidedBy ?? '—'}</code></td>
|
|
2127
2442
|
<td data-label="Asked">${ago(entry.createdAt, now())}</td>
|
|
2128
2443
|
</tr>`);
|
|
2129
|
-
const content = html `${noticeOf(notice)}
|
|
2444
|
+
const content = html `${noticeOf(notice, status)}
|
|
2130
2445
|
${pageHead('Approvals')}
|
|
2131
2446
|
<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
2447
|
<div class="stats">
|
|
@@ -2177,7 +2492,35 @@ ${decided.length === 0
|
|
|
2177
2492
|
const OWN_HOST_REFUSAL = 'That URL is this Slotlock deployment’s own address. Give the address of the model’s API.';
|
|
2178
2493
|
const assistantTimeout = assistantOptions?.timeoutMs ?? 30_000;
|
|
2179
2494
|
const assistantTurnTimeout = assistantOptions?.turnTimeoutMs ?? 90_000;
|
|
2180
|
-
|
|
2495
|
+
// Kept until the member deletes a conversation, unless the deployment gives them a lifetime.
|
|
2496
|
+
const conversationTtl = assistantOptions?.conversationTtlMs ?? null;
|
|
2497
|
+
/** How recently a conversation was used for the assistant page to open on it rather than a new one. */
|
|
2498
|
+
const CHAT_RESUME_MS = 2 * 3_600_000;
|
|
2499
|
+
const newChatId = () => randomBytes(12).toString('base64url');
|
|
2500
|
+
/** The conversation a page or form names, or null for a new one. */
|
|
2501
|
+
const chatOf = (value) => value && SLOTLOCK_ASSISTANT_CHAT_ID.test(value) ? value : null;
|
|
2502
|
+
const chatUrl = (id) => `${root}/assistant?c=${id ?? 'new'}`;
|
|
2503
|
+
/**
|
|
2504
|
+
* The conversation a form goes to: the one its `chat` names, a new one when it is empty, and with
|
|
2505
|
+
* no `chat` at all (a page of an earlier release), the one the page would open: the member's most
|
|
2506
|
+
* recent, used in the past two hours (`anyAge`: however long ago).
|
|
2507
|
+
*/
|
|
2508
|
+
async function chatOfForm(store, session, form, anyAge = false) {
|
|
2509
|
+
if (form.has('chat'))
|
|
2510
|
+
return chatOf(form.get('chat'));
|
|
2511
|
+
const [latest] = await store.listChats(tenantOf(session), personOf(session), 1);
|
|
2512
|
+
return latest && (anyAge || now() - latest.updatedAt.getTime() < CHAT_RESUME_MS) ? latest.id : null;
|
|
2513
|
+
}
|
|
2514
|
+
/** A conversation's first title: its first message on one line, cut at a word near 60 characters. */
|
|
2515
|
+
const chatTitle = (text) => {
|
|
2516
|
+
const line = text.replace(/\s+/gu, ' ').trim();
|
|
2517
|
+
const chars = [...line];
|
|
2518
|
+
if (chars.length <= 60)
|
|
2519
|
+
return line || 'New chat';
|
|
2520
|
+
const cut = chars.slice(0, 60).join('');
|
|
2521
|
+
const space = cut.lastIndexOf(' ');
|
|
2522
|
+
return `${(space > 30 ? cut.slice(0, space) : cut).replace(/[\s.,;:!?-]+$/u, '')}…`;
|
|
2523
|
+
};
|
|
2181
2524
|
if (assistantOptions && Buffer.byteLength(assistantOptions.secret, 'utf8') < 32) {
|
|
2182
2525
|
throw new Error('Slotlock dashboard assistant secret must be at least 32 bytes');
|
|
2183
2526
|
}
|
|
@@ -2367,38 +2710,127 @@ ${decided.length === 0
|
|
|
2367
2710
|
model: { provider: settings.provider, baseUrl: settings.baseUrl, model: settings.model, apiKey },
|
|
2368
2711
|
};
|
|
2369
2712
|
}
|
|
2713
|
+
/** When a change happens, in its resource's time zone when the card knows it, else UTC. */
|
|
2714
|
+
function whenOf(start, end, zone) {
|
|
2715
|
+
if (typeof start !== 'string')
|
|
2716
|
+
return null;
|
|
2717
|
+
const from = new Date(start);
|
|
2718
|
+
if (Number.isNaN(from.getTime()))
|
|
2719
|
+
return null;
|
|
2720
|
+
const timeZone = zone ?? 'UTC';
|
|
2721
|
+
let day;
|
|
2722
|
+
let time;
|
|
2723
|
+
try {
|
|
2724
|
+
day = new Intl.DateTimeFormat('en-GB', { weekday: 'short', day: 'numeric', month: 'short', timeZone });
|
|
2725
|
+
time = new Intl.DateTimeFormat('en-GB', { hour: '2-digit', minute: '2-digit', hourCycle: 'h23', timeZone });
|
|
2726
|
+
}
|
|
2727
|
+
catch {
|
|
2728
|
+
return null;
|
|
2729
|
+
}
|
|
2730
|
+
const to = typeof end === 'string' ? new Date(end) : null;
|
|
2731
|
+
const span = to && !Number.isNaN(to.getTime())
|
|
2732
|
+
? day.format(from) === day.format(to)
|
|
2733
|
+
? `${time.format(from)}–${time.format(to)}`
|
|
2734
|
+
: `${time.format(from)} – ${day.format(to)}, ${time.format(to)}`
|
|
2735
|
+
: time.format(from);
|
|
2736
|
+
return `${day.format(from)}, ${span} ${timeZone}`;
|
|
2737
|
+
}
|
|
2738
|
+
/** What a proposed change's fields are called on its card. */
|
|
2739
|
+
const FACT_NAMES = Object.freeze({
|
|
2740
|
+
resource_id: 'Resource',
|
|
2741
|
+
resource_ids: 'Resources',
|
|
2742
|
+
pool_id: 'Pool',
|
|
2743
|
+
title: 'Title',
|
|
2744
|
+
external_ref: 'Reference',
|
|
2745
|
+
event_id: 'Booking',
|
|
2746
|
+
hold_id: 'Hold',
|
|
2747
|
+
hold_group_id: 'Held together',
|
|
2748
|
+
ttl_seconds: 'Held for',
|
|
2749
|
+
notes: 'Note',
|
|
2750
|
+
description: 'Note',
|
|
2751
|
+
metadata: 'Details',
|
|
2752
|
+
slots: 'Times',
|
|
2753
|
+
});
|
|
2370
2754
|
/**
|
|
2371
|
-
* A field of a proposed change, as the card shows it: all of it, wrapped, never cut (the
|
|
2372
|
-
*
|
|
2755
|
+
* A field of a proposed change, as the card shows it: all of it, wrapped, never cut (the proposal
|
|
2756
|
+
* is bounded instead). A resource is named; its id is the name's title.
|
|
2373
2757
|
*/
|
|
2374
2758
|
function fieldValue(name, value, names) {
|
|
2375
|
-
const named = (id) =>
|
|
2376
|
-
if (typeof value === 'string')
|
|
2377
|
-
return
|
|
2378
|
-
|
|
2759
|
+
const named = (id) => names.has(id) ? html `<span title="${id}">${names.get(id)}</span>` : html `<code>${id}</code>`;
|
|
2760
|
+
if (name === 'resource_id' && typeof value === 'string')
|
|
2761
|
+
return named(value);
|
|
2762
|
+
if (name === 'resource_ids' && Array.isArray(value) && value.every((item) => typeof item === 'string')) {
|
|
2763
|
+
return html `${value.map((id, index) => html `${index > 0 ? ', ' : ''}${named(id)}`)}`;
|
|
2764
|
+
}
|
|
2765
|
+
if (name === 'ttl_seconds' && typeof value === 'number') {
|
|
2766
|
+
return html `${value >= 60 ? `${Math.round(value / 60)} min` : `${value} s`}`;
|
|
2767
|
+
}
|
|
2768
|
+
if ((name === 'event_id' || name === 'hold_id' || name === 'hold_group_id') && typeof value === 'string')
|
|
2769
|
+
return html `<code>${value}</code>`;
|
|
2770
|
+
if (typeof value === 'string')
|
|
2771
|
+
return html `<span class="wrap">${value}</span>`;
|
|
2379
2772
|
if (Array.isArray(value) && value.every((item) => typeof item === 'string')) {
|
|
2380
|
-
return html `<span class="wrap">${value.
|
|
2773
|
+
return html `<span class="wrap">${value.join(', ')}</span>`;
|
|
2381
2774
|
}
|
|
2382
|
-
if (typeof value === 'number' || typeof value === 'boolean' || value === null)
|
|
2775
|
+
if (typeof value === 'number' || typeof value === 'boolean' || value === null)
|
|
2383
2776
|
return html `${String(value)}`;
|
|
2384
|
-
}
|
|
2385
|
-
return html `<pre class="wrap">${JSON.stringify(value, null, 2)}</pre>`;
|
|
2777
|
+
return html `<pre>${JSON.stringify(value, null, 2)}</pre>`;
|
|
2386
2778
|
}
|
|
2387
|
-
|
|
2779
|
+
/** Why a change or a step was refused, in words, for the codes a member meets. */
|
|
2780
|
+
const REFUSALS = Object.freeze({
|
|
2781
|
+
overlap: 'that time was just taken',
|
|
2782
|
+
pool_full: 'the pool is full then',
|
|
2783
|
+
not_found: 'it no longer exists',
|
|
2784
|
+
resource_not_found: 'the resource no longer exists',
|
|
2785
|
+
event_not_found: 'the booking no longer exists',
|
|
2786
|
+
invalid_input: 'the request was incomplete',
|
|
2787
|
+
invalid_arguments: 'the request was incomplete',
|
|
2788
|
+
validation_error: 'the request was incomplete',
|
|
2789
|
+
turn_read_budget_exceeded: 'one message may read only so much',
|
|
2790
|
+
too_many_pending_changes: 'too many changes are waiting for you',
|
|
2791
|
+
change_too_large_to_show: 'the change was too large to show',
|
|
2792
|
+
tool_not_available: 'the assistant may not do that',
|
|
2793
|
+
too_many_calls: 'too many at once',
|
|
2794
|
+
quota_exceeded: "this month's allowance is used up",
|
|
2795
|
+
approval_pending: 'it is waiting for an approval',
|
|
2796
|
+
});
|
|
2797
|
+
const refusal = (code) => REFUSALS[code] ?? code.replaceAll('_', ' ');
|
|
2798
|
+
function actionCard(session, action, names, zones, chatId) {
|
|
2799
|
+
const verb = slotlockAssistantActionVerb(action.operation);
|
|
2800
|
+
const title = action.summary || `${verb.verb}: ${toolTitles.get(action.operation) ?? action.operation}`;
|
|
2801
|
+
if (action.status !== 'pending') {
|
|
2802
|
+
const [tone, word] = action.status === 'done'
|
|
2803
|
+
? ['ok', verb.done]
|
|
2804
|
+
: action.status === 'dismissed'
|
|
2805
|
+
? ['', 'Declined']
|
|
2806
|
+
: action.status === 'approved'
|
|
2807
|
+
? ['', 'Running…']
|
|
2808
|
+
: ['bad', `Refused: ${refusal(action.outcome ?? 'failed')}`];
|
|
2809
|
+
return html `<li class="turn action" id="action-${action.id}"><p class="receipt ${tone}"><span>${title}</span><span class="why">${word}</span></p></li>`;
|
|
2810
|
+
}
|
|
2388
2811
|
const token = csrfToken(session);
|
|
2389
|
-
const
|
|
2390
|
-
|
|
2391
|
-
const
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
const
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2812
|
+
const input = Object.entries(action.input).filter(([name]) => name !== 'idempotency_key');
|
|
2813
|
+
// The change's own time zone when it gives one, else its resource's.
|
|
2814
|
+
const zone = typeof action.input.timezone === 'string'
|
|
2815
|
+
? action.input.timezone
|
|
2816
|
+
: typeof action.input.resource_id === 'string'
|
|
2817
|
+
? zones.get(action.input.resource_id)
|
|
2818
|
+
: undefined;
|
|
2819
|
+
const when = whenOf(action.input.starts_at, action.input.ends_at, zone);
|
|
2820
|
+
const facts = [];
|
|
2821
|
+
const more = [];
|
|
2822
|
+
if (when)
|
|
2823
|
+
facts.push(html `<dt>When</dt><dd>${when}</dd>`);
|
|
2824
|
+
for (const [name, value] of input) {
|
|
2825
|
+
if (when && (name === 'starts_at' || name === 'ends_at' || name === 'timezone'))
|
|
2826
|
+
continue;
|
|
2827
|
+
const label = FACT_NAMES[name] ?? name.charAt(0).toUpperCase() + name.slice(1).replaceAll('_', ' ');
|
|
2828
|
+
(name in FACT_NAMES || name === 'attendees' ? facts : more).push(html `<dt>${label}</dt><dd>${fieldValue(name, value, names)}</dd>`);
|
|
2829
|
+
}
|
|
2830
|
+
const hidden = (id) => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="chat" value="${chatId ?? ''}"><input type="hidden" name="action" value="${id}">`;
|
|
2831
|
+
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
|
|
2832
|
+
? html `<details class="more"><summary>All details</summary><dl class="facts">${more}</dl></details>`
|
|
2833
|
+
: 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
2834
|
}
|
|
2403
2835
|
function readAction(id, value) {
|
|
2404
2836
|
if (!isRecord(value) || typeof value.operation !== 'string')
|
|
@@ -2412,100 +2844,282 @@ ${decided.length === 0
|
|
|
2412
2844
|
...(typeof value.outcome === 'string' ? { outcome: value.outcome } : {}),
|
|
2413
2845
|
};
|
|
2414
2846
|
}
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
store.getSettings(tenantRef),
|
|
2422
|
-
store.getConversation(tenantRef, personOf(session)),
|
|
2423
|
-
]);
|
|
2424
|
-
const entries = readSlotlockAssistantEntries(conversation?.messages);
|
|
2425
|
-
const actions = conversation?.actions ?? {};
|
|
2426
|
-
const names = new Map();
|
|
2427
|
-
if (Object.keys(actions).length > 0) {
|
|
2428
|
-
for (const resource of await options.resources.list(tenantRef)) {
|
|
2429
|
-
names.set(resource.id, resourceName(resource, resource.id));
|
|
2430
|
-
}
|
|
2847
|
+
/** The code a tool result was refused with, from the JSON the model was given. */
|
|
2848
|
+
function refusedCode(content) {
|
|
2849
|
+
try {
|
|
2850
|
+
const parsed = JSON.parse(content);
|
|
2851
|
+
const error = isRecord(parsed) && isRecord(parsed.error) ? parsed.error : null;
|
|
2852
|
+
return error && typeof error.code === 'string' ? error.code : null;
|
|
2431
2853
|
}
|
|
2432
|
-
|
|
2854
|
+
catch {
|
|
2855
|
+
return null;
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2858
|
+
/** Notes the turn ends with that are not a failure: a member's Stop, and the step bound. */
|
|
2859
|
+
const quietNote = (text) => text === 'Stopped.' || text.startsWith('The assistant stopped after');
|
|
2860
|
+
/**
|
|
2861
|
+
* The conversation as the thread shows it: the member's messages as bubbles, the replies as
|
|
2862
|
+
* rendered Markdown, each run of tool steps folded into one line, proposed changes as cards, and
|
|
2863
|
+
* a failure that ends it with a way to send the last message again.
|
|
2864
|
+
*/
|
|
2865
|
+
function assistantItems(session, entries, actions, names, zones, chatId) {
|
|
2433
2866
|
const items = [];
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2867
|
+
let steps = [];
|
|
2868
|
+
let cards = [];
|
|
2869
|
+
const flush = () => {
|
|
2870
|
+
if (steps.length > 0) {
|
|
2871
|
+
const bad = steps.some((step) => !step.ok);
|
|
2872
|
+
const seen = [];
|
|
2873
|
+
for (const step of steps)
|
|
2874
|
+
if (!seen.includes(step.label))
|
|
2875
|
+
seen.push(step.label);
|
|
2876
|
+
const summary = seen.map((label, index) => (index === 0 ? label : label.charAt(0).toLowerCase() + label.slice(1))).join(', ');
|
|
2877
|
+
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>`);
|
|
2878
|
+
steps = [];
|
|
2879
|
+
}
|
|
2880
|
+
items.push(...cards);
|
|
2881
|
+
cards = [];
|
|
2882
|
+
};
|
|
2883
|
+
const lastUser = [...entries].reverse().find((entry) => entry.role === 'user');
|
|
2884
|
+
for (const [index, entry] of entries.entries()) {
|
|
2885
|
+
if (entry.role === 'user') {
|
|
2886
|
+
flush();
|
|
2887
|
+
items.push(html `<li class="turn mine"><div class="bubble">${entry.text}</div></li>`);
|
|
2888
|
+
}
|
|
2889
|
+
else if (entry.role === 'assistant') {
|
|
2890
|
+
if (entry.text.trim() === '')
|
|
2891
|
+
continue;
|
|
2892
|
+
flush();
|
|
2893
|
+
items.push(html `<li class="turn bot"><div class="prose">${new Html(renderSlotlockMarkdown(entry.text))}</div></li>`);
|
|
2439
2894
|
}
|
|
2440
|
-
else if (entry.role === 'note')
|
|
2441
|
-
items.push(html `<li class="notice">${entry.text}</li>`);
|
|
2442
2895
|
else if (entry.role === 'tool') {
|
|
2443
2896
|
for (const result of entry.results) {
|
|
2897
|
+
const write = SLOTLOCK_ASSISTANT_TOOL_USE[result.name] === 'propose';
|
|
2444
2898
|
const action = result.action ? readAction(result.action, actions[result.action]) : null;
|
|
2445
|
-
if (action)
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
items.push(html `<li class="tool-line">${result.error ? 'Could not run' : 'Read'}: ${toolTitles.get(result.name) ?? result.name}</li>`);
|
|
2899
|
+
if (action) {
|
|
2900
|
+
cards.push(actionCard(session, action, names, zones, chatId));
|
|
2901
|
+
continue;
|
|
2449
2902
|
}
|
|
2903
|
+
const title = toolTitles.get(result.name) ?? result.name;
|
|
2904
|
+
const code = result.error ? refusedCode(result.content) : null;
|
|
2905
|
+
steps.push({
|
|
2906
|
+
label: slotlockAssistantStepLabel(result.name, title, result.error ? 'bad' : 'ok', write),
|
|
2907
|
+
ok: !result.error,
|
|
2908
|
+
why: code ? refusal(code) : null,
|
|
2909
|
+
});
|
|
2910
|
+
}
|
|
2911
|
+
}
|
|
2912
|
+
else if (entry.role === 'note') {
|
|
2913
|
+
flush();
|
|
2914
|
+
const last = index === entries.length - 1;
|
|
2915
|
+
if (last && !quietNote(entry.text) && lastUser?.role === 'user') {
|
|
2916
|
+
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="chat" value="${chatId ?? ''}"><input type="hidden" name="message" value="${lastUser.text}"><button class="text-button" type="submit">Retry</button></form></li>`);
|
|
2917
|
+
}
|
|
2918
|
+
else {
|
|
2919
|
+
items.push(html `<li class="turn note">${entry.text}</li>`);
|
|
2450
2920
|
}
|
|
2451
2921
|
}
|
|
2452
2922
|
}
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2923
|
+
flush();
|
|
2924
|
+
return items;
|
|
2925
|
+
}
|
|
2926
|
+
/** The tenant's resources as a proposed change's card names them, and each one's time zone. */
|
|
2927
|
+
async function resourceLookups(tenantRef) {
|
|
2928
|
+
const resources = await options.resources.list(tenantRef);
|
|
2929
|
+
return {
|
|
2930
|
+
resources,
|
|
2931
|
+
names: new Map(resources.map((resource) => [resource.id, resourceName(resource, resource.id)])),
|
|
2932
|
+
zones: new Map(resources.flatMap((resource) => (resource.timezone ? [[resource.id, resource.timezone]] : []))),
|
|
2933
|
+
};
|
|
2934
|
+
}
|
|
2935
|
+
const ASSISTANT_ICONS = {
|
|
2936
|
+
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>'),
|
|
2937
|
+
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>'),
|
|
2938
|
+
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>'),
|
|
2939
|
+
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>'),
|
|
2940
|
+
more: new Html('<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><circle cx="3.5" cy="8" r="1.3"/><circle cx="8" cy="8" r="1.3"/><circle cx="12.5" cy="8" r="1.3"/></svg>'),
|
|
2941
|
+
chats: new Html('<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 4h10M3 8h10M3 12h6"/></svg>'),
|
|
2942
|
+
};
|
|
2943
|
+
/** How long ago a chat was used, as briefly as the list has room for: 4m, 3h, 5d, then the date. */
|
|
2944
|
+
function chatSince(at) {
|
|
2945
|
+
const minutes = Math.max(0, Math.floor((now() - at.getTime()) / 60_000));
|
|
2946
|
+
if (minutes < 1)
|
|
2947
|
+
return 'now';
|
|
2948
|
+
if (minutes < 60)
|
|
2949
|
+
return `${String(minutes)}m`;
|
|
2950
|
+
if (minutes < 24 * 60)
|
|
2951
|
+
return `${String(Math.floor(minutes / 60))}h`;
|
|
2952
|
+
if (minutes < 7 * 24 * 60)
|
|
2953
|
+
return `${String(Math.floor(minutes / (24 * 60)))}d`;
|
|
2954
|
+
return zoned('UTC', { day: 'numeric', month: 'short' }).format(at);
|
|
2955
|
+
}
|
|
2956
|
+
/** When a conversation was last used, as the Chats list groups them: by day, in UTC. */
|
|
2957
|
+
function chatGroup(at) {
|
|
2958
|
+
const day = (ms) => Math.floor(ms / DAY_MS);
|
|
2959
|
+
const daysAgo = day(now()) - day(at.getTime());
|
|
2960
|
+
if (daysAgo <= 0)
|
|
2961
|
+
return 'Today';
|
|
2962
|
+
if (daysAgo === 1)
|
|
2963
|
+
return 'Yesterday';
|
|
2964
|
+
if (daysAgo < 7)
|
|
2965
|
+
return 'Previous 7 days';
|
|
2966
|
+
if (daysAgo < 30)
|
|
2967
|
+
return 'Previous 30 days';
|
|
2968
|
+
return 'Older';
|
|
2969
|
+
}
|
|
2970
|
+
/**
|
|
2971
|
+
* A member's conversations, the most recent first, grouped by when they were last used: each
|
|
2972
|
+
* opens with a link, and has Rename and Delete behind its menu. `current` is the one shown.
|
|
2973
|
+
*/
|
|
2974
|
+
function chatsPanel(session, chats, current) {
|
|
2975
|
+
const token = csrfToken(session);
|
|
2976
|
+
const hidden = (id) => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="chat" value="${id}">`;
|
|
2977
|
+
const groups = [];
|
|
2978
|
+
for (const chat of chats) {
|
|
2979
|
+
const label = chatGroup(chat.updatedAt);
|
|
2980
|
+
let group = groups.at(-1);
|
|
2981
|
+
if (!group || group.label !== label) {
|
|
2982
|
+
group = { label, rows: [] };
|
|
2983
|
+
groups.push(group);
|
|
2984
|
+
}
|
|
2985
|
+
const here = chat.id === current;
|
|
2986
|
+
group.rows.push(html `<li class="chat-row${here ? ' current' : ''}"><a class="chat-link" href="${chatUrl(chat.id)}"${here ? html ` aria-current="page"` : html ``}><span class="chat-title">${chat.title}</span><time class="chat-when" datetime="${chat.updatedAt.toISOString()}" title="${formatDate(chat.updatedAt)}">${chatSince(chat.updatedAt)}</time></a><details class="chat-menu"><summary aria-label="Options for ${chat.title}">${ASSISTANT_ICONS.more}</summary><div class="pop"><form class="chat-rename" method="post" action="${root}/assistant/rename">${hidden(chat.id)}<label class="visually-hidden" for="title-${chat.id}">Title</label><input id="title-${chat.id}" name="title" value="${chat.title}" maxlength="120" required><button type="submit">Rename</button></form><form method="post" action="${root}/assistant/delete">${hidden(chat.id)}<button class="danger" type="submit">Delete chat</button></form></div></details></li>`);
|
|
2987
|
+
}
|
|
2988
|
+
const list = groups.length === 0
|
|
2989
|
+
? html `<p class="empty chats-empty">No chats yet.</p>`
|
|
2990
|
+
: html `${groups.map((group) => html `<h3 class="chat-group">${group.label}</h3><ul class="chat-rows">${group.rows}</ul>`)}`;
|
|
2991
|
+
return html `<aside class="chats" aria-label="Chats"><div class="chats-head"><h2>Chats</h2><a class="icon-button" href="${chatUrl(null)}" aria-label="New chat" title="New chat">${ASSISTANT_ICONS.plus}</a></div><label class="visually-hidden" for="chats-filter">Search chats</label><input id="chats-filter" class="chats-filter" type="search" placeholder="Search chats" autocomplete="off" hidden><nav class="chat-list" aria-label="Your chats">${list}</nav></aside>`;
|
|
2992
|
+
}
|
|
2993
|
+
/**
|
|
2994
|
+
* The assistant: a conversation, with the member's others beside it. `requested` is the page's
|
|
2995
|
+
* `c`: a conversation's id, `new` for a new one, or nothing for the last one used in the past two
|
|
2996
|
+
* hours (else a new one).
|
|
2997
|
+
*/
|
|
2998
|
+
async function assistantPage(session, status = 200, notice, requested) {
|
|
2999
|
+
const store = assistantStore();
|
|
3000
|
+
if (!store)
|
|
3001
|
+
return notFound(session);
|
|
3002
|
+
const tenantRef = tenantOf(session);
|
|
3003
|
+
const member = personOf(session);
|
|
3004
|
+
const [settings, chats] = await Promise.all([store.getSettings(tenantRef), store.listChats(tenantRef, member)]);
|
|
3005
|
+
if (!settings) {
|
|
3006
|
+
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)}`;
|
|
3007
|
+
return respond(status, layout('Assistant', content, session, 'assistant'));
|
|
3008
|
+
}
|
|
3009
|
+
let chatId = chatOf(requested);
|
|
3010
|
+
if (requested === undefined) {
|
|
3011
|
+
const latest = chats[0];
|
|
3012
|
+
if (latest && now() - latest.updatedAt.getTime() < CHAT_RESUME_MS)
|
|
3013
|
+
chatId = latest.id;
|
|
3014
|
+
}
|
|
3015
|
+
const conversation = chatId ? await store.getChat(tenantRef, member, chatId) : null;
|
|
3016
|
+
if (chatId && !conversation) {
|
|
3017
|
+
// Deleted, or never this member's: a new one instead, saying so.
|
|
3018
|
+
chatId = null;
|
|
3019
|
+
if (requested !== undefined && notice === undefined) {
|
|
3020
|
+
notice = 'That chat is not here: it was deleted.';
|
|
3021
|
+
status = 404;
|
|
3022
|
+
}
|
|
3023
|
+
}
|
|
3024
|
+
const entries = readSlotlockAssistantEntries(conversation?.messages);
|
|
3025
|
+
const actions = conversation?.actions ?? {};
|
|
3026
|
+
const empty = entries.length === 0;
|
|
3027
|
+
const { resources, names, zones } = empty || Object.keys(actions).length > 0
|
|
3028
|
+
? await resourceLookups(tenantRef)
|
|
3029
|
+
: { resources: [], names: new Map(), zones: new Map() };
|
|
3030
|
+
const token = csrfToken(session);
|
|
3031
|
+
const hidden = () => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="chat" value="${chatId ?? ''}">`;
|
|
3032
|
+
const first = resources.find((resource) => !resource.archivedAt);
|
|
3033
|
+
const named = first ? resourceName(first, first.id) : null;
|
|
3034
|
+
// A read chip asks at once; a write chip fills the box to finish (app.js), or asks (no script).
|
|
3035
|
+
const prompts = [
|
|
3036
|
+
{ label: "What's on today", message: "What's booked today?" },
|
|
3037
|
+
{ label: 'Find free time', message: named ? `When is ${named} free tomorrow morning?` : 'What is free tomorrow morning?' },
|
|
3038
|
+
{ label: 'Book', message: named ? `Book ${named} tomorrow 9:00–10:00` : 'Book ', fill: true },
|
|
3039
|
+
{ label: 'Move a booking', message: 'Move the next booking one hour later', fill: true },
|
|
3040
|
+
{ label: 'Cancel a booking', message: 'Cancel the next booking', fill: true },
|
|
3041
|
+
];
|
|
3042
|
+
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>`;
|
|
3043
|
+
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>`;
|
|
3044
|
+
const actionsHead = html `<div class="assistant-actions"><a class="button ghost chats-link" href="${root}/assistant/chats">${ASSISTANT_ICONS.chats}Chats</a><a class="button ghost new-chat" href="${chatUrl(null)}">${ASSISTANT_ICONS.plus}New chat</a></div>`;
|
|
3045
|
+
const flag = empty ? new Html(' data-empty') : html ``;
|
|
3046
|
+
const content = html `${noticeOf(notice, status)}<div class="assistant-shell">${chatsPanel(session, chats, chatId)}<div class="assistant-page"${flag}>${pageHead(conversation?.title ?? 'Assistant', actionsHead)}<div class="assistant" data-assistant data-stream="${root}/assistant/stream" data-page="${root}/assistant"${flag}><ol class="thread">${assistantItems(session, entries, actions, names, zones, chatId)}</ol><span id="latest"></span>${chips}${composer}</div></div></div>`;
|
|
2466
3047
|
return respond(status, layout('Assistant', content, session, 'assistant'));
|
|
2467
3048
|
}
|
|
2468
|
-
|
|
3049
|
+
/** The member's conversations on a page of their own: the Chats list where the page is narrow. */
|
|
3050
|
+
async function chatsPage(session) {
|
|
2469
3051
|
const store = assistantStore();
|
|
2470
3052
|
if (!store)
|
|
2471
|
-
return
|
|
3053
|
+
return notFound(session);
|
|
3054
|
+
const chats = await store.listChats(tenantOf(session), personOf(session));
|
|
3055
|
+
const content = html `<div class="chats-page">${pageHead('Chats', html `<a class="button ghost" href="${chatUrl(null)}">${ASSISTANT_ICONS.plus}New chat</a>`)}${chatsPanel(session, chats, null)}</div>`;
|
|
3056
|
+
return respond(200, layout('Chats', content, session, 'assistant'));
|
|
3057
|
+
}
|
|
3058
|
+
/** The named provider a provider and base URL are, or none (Custom). */
|
|
3059
|
+
const presetOf = (provider, baseUrl) => SLOTLOCK_ASSISTANT_PRESETS.find((preset) => preset.id !== 'custom' && preset.provider === provider && preset.baseUrl === baseUrl);
|
|
3060
|
+
/**
|
|
3061
|
+
* The assistant's settings: what is connected and whether it answers (Test, its result in place),
|
|
3062
|
+
* then the form, one provider chosen from tiles, and Disconnect apart. A member sees only what is
|
|
3063
|
+
* connected, and where their messages go.
|
|
3064
|
+
*/
|
|
3065
|
+
async function assistantSettingsSection(session, status, notice, draft) {
|
|
3066
|
+
const store = assistantStore();
|
|
2472
3067
|
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
3068
|
const token = csrfToken(session);
|
|
3069
|
+
const hidden = () => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}">`;
|
|
3070
|
+
const savedName = settings ? (presetOf(settings.provider, settings.baseUrl)?.name ?? 'Custom') : null;
|
|
3071
|
+
// Where messages go, in words: the provider's name, or a custom one's host.
|
|
3072
|
+
const hostOf = (url) => {
|
|
3073
|
+
try {
|
|
3074
|
+
return new URL(url).host;
|
|
3075
|
+
}
|
|
3076
|
+
catch {
|
|
3077
|
+
return url;
|
|
3078
|
+
}
|
|
3079
|
+
};
|
|
3080
|
+
const savedTo = settings ? (savedName === 'Custom' ? hostOf(settings.baseUrl) : savedName) : null;
|
|
3081
|
+
const admin = isAdmin(session);
|
|
3082
|
+
const badge = !settings
|
|
3083
|
+
? html `<span class="badge">Not set up</span>`
|
|
3084
|
+
: notice && status === 200 && notice.startsWith('Connected')
|
|
3085
|
+
? html `<span class="badge ok">Connected</span>`
|
|
3086
|
+
: notice && status === 502
|
|
3087
|
+
? html `<span class="badge bad">Failing</span>`
|
|
3088
|
+
: html `<span class="badge ok">Set up</span>`;
|
|
3089
|
+
const test = settings && admin
|
|
3090
|
+
? html `<form method="post" action="${root}/settings/assistant/test">${hidden()}<button type="submit">Test</button></form>`
|
|
3091
|
+
: html ``;
|
|
3092
|
+
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>`;
|
|
3093
|
+
const sentTo = (name) => html `<p class="fine">Members' messages, and the bookings they ask about, are sent to ${name ?? 'the provider you choose'}.</p>`;
|
|
3094
|
+
if (!admin) {
|
|
3095
|
+
return html `${statusCard}${settings ? sentTo(savedTo) : html `<p class="fine">An admin of this organization connects a model here.</p>`}`;
|
|
3096
|
+
}
|
|
2480
3097
|
// What the form shows: a draft (a preset, or a model list) over what is saved.
|
|
2481
|
-
const provider = draft?.provider ?? settings?.provider;
|
|
3098
|
+
const provider = draft?.provider ?? settings?.provider ?? 'anthropic';
|
|
2482
3099
|
const baseUrl = draft ? draft.baseUrl : (settings?.baseUrl ?? '');
|
|
2483
3100
|
const model = draft?.model ?? settings?.model ?? '';
|
|
2484
|
-
const
|
|
3101
|
+
const chosen = settings || draft ? (presetOf(provider, baseUrl)?.id ?? 'custom') : 'anthropic';
|
|
3102
|
+
const chosenName = chosen === 'custom' ? (baseUrl ? hostOf(baseUrl) : null) : (SLOTLOCK_ASSISTANT_PRESETS.find((preset) => preset.id === chosen)?.name ?? null);
|
|
2485
3103
|
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
|
|
3104
|
+
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>`;
|
|
3105
|
+
const format = (value, label) => html `<label><input type="radio" name="provider" value="${value}"${provider === value ? html ` checked` : html ``}>${label}</label>`;
|
|
3106
|
+
const keyField = settings?.keyHint
|
|
3107
|
+
? 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>`
|
|
3108
|
+
: html `<input id="api-key" name="api_key" type="password" autocomplete="off" maxlength="4096" placeholder="Optional for a local model">`;
|
|
3109
|
+
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
3110
|
? 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
|
-
|
|
3111
|
+
: 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>`;
|
|
3112
|
+
const disconnect = settings
|
|
3113
|
+
? 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>`
|
|
3114
|
+
: html ``;
|
|
3115
|
+
return html `${statusCard}${form}${disconnect}`;
|
|
3116
|
+
}
|
|
3117
|
+
/** The provider and base URL a form names: a named provider's own, or Custom's two fields. */
|
|
3118
|
+
function formEndpoint(form) {
|
|
3119
|
+
const preset = SLOTLOCK_ASSISTANT_PRESETS.find((candidate) => candidate.id === form.get('preset'));
|
|
3120
|
+
if (preset && preset.id !== 'custom')
|
|
3121
|
+
return { provider: preset.provider, baseUrl: preset.baseUrl };
|
|
3122
|
+
return { provider: form.get('provider'), baseUrl: (form.get('base_url') ?? '').trim() };
|
|
2509
3123
|
}
|
|
2510
3124
|
const adminOnly = (session) => settingsPage(session, 403, 'Only an owner of this organization can change the assistant.');
|
|
2511
3125
|
async function saveAssistantSettings(session, form) {
|
|
@@ -2514,8 +3128,8 @@ ${settings
|
|
|
2514
3128
|
return notFound(session);
|
|
2515
3129
|
if (!isAdmin(session))
|
|
2516
3130
|
return adminOnly(session);
|
|
2517
|
-
const provider = form
|
|
2518
|
-
const checkedUrl = readSlotlockAssistantBaseUrl(
|
|
3131
|
+
const { provider, baseUrl: givenUrl } = formEndpoint(form);
|
|
3132
|
+
const checkedUrl = readSlotlockAssistantBaseUrl(givenUrl, { privateUrls });
|
|
2519
3133
|
const model = (form.get('model') ?? '').trim();
|
|
2520
3134
|
const apiKey = (form.get('api_key') ?? '').trim();
|
|
2521
3135
|
if (provider !== 'anthropic' && provider !== 'openai') {
|
|
@@ -2566,14 +3180,14 @@ ${settings
|
|
|
2566
3180
|
if (!memberMessages.take(`${tenantOf(session)}\0${session.uid}`)) {
|
|
2567
3181
|
return tooManyMessages(session, settingsPage);
|
|
2568
3182
|
}
|
|
2569
|
-
const provider = form
|
|
3183
|
+
const { provider, baseUrl: givenUrl } = formEndpoint(form);
|
|
2570
3184
|
if (provider !== 'anthropic' && provider !== 'openai') {
|
|
2571
3185
|
return settingsPage(session, 400, 'Choose a provider from the list.');
|
|
2572
3186
|
}
|
|
2573
3187
|
const typedModel = (form.get('model') ?? '').trim();
|
|
2574
3188
|
const draft = {
|
|
2575
3189
|
provider,
|
|
2576
|
-
baseUrl:
|
|
3190
|
+
baseUrl: givenUrl,
|
|
2577
3191
|
...(typedModel !== '' ? { model: typedModel } : {}),
|
|
2578
3192
|
};
|
|
2579
3193
|
const checkedUrl = readSlotlockAssistantBaseUrl(draft.baseUrl, { privateUrls });
|
|
@@ -2655,22 +3269,34 @@ ${settings
|
|
|
2655
3269
|
return settingsPage(session, 502, slotlockAssistantProviderMessage(error));
|
|
2656
3270
|
}
|
|
2657
3271
|
}
|
|
2658
|
-
|
|
3272
|
+
/**
|
|
3273
|
+
* One member message: check it, run the turn (streaming its text and steps to `onEvent` when
|
|
3274
|
+
* given, stopping when `signal` aborts), and keep the conversation. Both ways to send share it.
|
|
3275
|
+
*/
|
|
3276
|
+
async function runAssistantMessage(session, form, hooks = {}) {
|
|
2659
3277
|
const store = assistantStore();
|
|
2660
3278
|
if (!store || !assistantOptions)
|
|
2661
|
-
return
|
|
3279
|
+
return { ok: false, status: 404, message: 'Nothing is here.' };
|
|
2662
3280
|
const text = (form.get('message') ?? '').trim();
|
|
2663
3281
|
if (text === '' || text.length > SLOTLOCK_ASSISTANT_MAX_MESSAGE) {
|
|
2664
|
-
return
|
|
3282
|
+
return {
|
|
3283
|
+
ok: false,
|
|
3284
|
+
status: 400,
|
|
3285
|
+
message: `Write a message of 1 to ${SLOTLOCK_ASSISTANT_MAX_MESSAGE.toLocaleString('en')} characters.`,
|
|
3286
|
+
};
|
|
2665
3287
|
}
|
|
2666
3288
|
if (!memberMessages.take(`${tenantOf(session)}\0${session.uid}`)) {
|
|
2667
|
-
return
|
|
3289
|
+
return {
|
|
3290
|
+
ok: false,
|
|
3291
|
+
status: 429,
|
|
3292
|
+
message: 'You have sent the assistant a lot in the last minute. Wait a moment and try again.',
|
|
3293
|
+
};
|
|
2668
3294
|
}
|
|
2669
3295
|
const found = await assistantModel(session);
|
|
2670
3296
|
if (found === null)
|
|
2671
|
-
return
|
|
3297
|
+
return { ok: false, status: 409, message: 'No model is connected.' };
|
|
2672
3298
|
if ('problem' in found)
|
|
2673
|
-
return
|
|
3299
|
+
return { ok: false, status: 409, message: found.problem };
|
|
2674
3300
|
const tenantRef = tenantOf(session);
|
|
2675
3301
|
const member = personOf(session);
|
|
2676
3302
|
try {
|
|
@@ -2679,7 +3305,16 @@ ${settings
|
|
|
2679
3305
|
catch {
|
|
2680
3306
|
// Metering never changes an answer.
|
|
2681
3307
|
}
|
|
2682
|
-
|
|
3308
|
+
// The conversation the form continues (a new one when it names none, or one deleted meanwhile),
|
|
3309
|
+
// and the resources agents may see, so a name is matched to its id without a lookup.
|
|
3310
|
+
const asked = await chatOfForm(store, session, form);
|
|
3311
|
+
const [stored, listed] = await Promise.all([
|
|
3312
|
+
asked ? store.getChat(tenantRef, member, asked) : Promise.resolve(null),
|
|
3313
|
+
options.resources.list(tenantRef).catch(() => []),
|
|
3314
|
+
]);
|
|
3315
|
+
const chatId = stored?.id ?? newChatId();
|
|
3316
|
+
const title = stored?.title ?? chatTitle(text);
|
|
3317
|
+
const bookable = listed.filter((resource) => resource.visibility !== 'private' && resource.archivedAt === undefined);
|
|
2683
3318
|
const entries = boundSlotlockAssistantEntries([
|
|
2684
3319
|
...readSlotlockAssistantEntries(stored?.messages),
|
|
2685
3320
|
{ role: 'user', text },
|
|
@@ -2696,12 +3331,25 @@ ${settings
|
|
|
2696
3331
|
workspace: current?.login ?? session.login,
|
|
2697
3332
|
login: session.login,
|
|
2698
3333
|
now: new Date(now()),
|
|
3334
|
+
resources: bookable.slice(0, SLOTLOCK_ASSISTANT_DIRECTORY_SIZE).map((resource) => ({
|
|
3335
|
+
id: resource.id,
|
|
3336
|
+
...(resource.name !== undefined ? { name: resource.name } : {}),
|
|
3337
|
+
...(resource.externalRef ? { ref: resource.externalRef } : {}),
|
|
3338
|
+
...(resource.kind !== undefined ? { kind: resource.kind } : {}),
|
|
3339
|
+
...(resource.locationName !== undefined || resource.location !== undefined
|
|
3340
|
+
? { location: (resource.locationName ?? resource.location) }
|
|
3341
|
+
: {}),
|
|
3342
|
+
timezone: resource.timezone,
|
|
3343
|
+
})),
|
|
3344
|
+
resourceCount: bookable.length,
|
|
2699
3345
|
}),
|
|
2700
3346
|
entries,
|
|
2701
3347
|
fetch: assistantFetch,
|
|
2702
3348
|
timeoutMs: assistantTimeout,
|
|
2703
3349
|
turnTimeoutMs: assistantTurnTimeout,
|
|
2704
3350
|
...(assistantOptions.maxSteps !== undefined ? { maxSteps: assistantOptions.maxSteps } : {}),
|
|
3351
|
+
...(hooks.onEvent ? { onEvent: hooks.onEvent } : {}),
|
|
3352
|
+
...(hooks.signal ? { signal: hooks.signal } : {}),
|
|
2705
3353
|
read: async (operation, input) => {
|
|
2706
3354
|
// A turn's reads share one budget of resource-days, so 8 steps of wide reads cannot each
|
|
2707
3355
|
// scan a hundred resources over years.
|
|
@@ -2753,134 +3401,374 @@ ${settings
|
|
|
2753
3401
|
// Only this turn's proposals are added; the rest are kept as the row holds them now, so one
|
|
2754
3402
|
// approved (or a conversation reset) while the turn ran is never put back as it stood.
|
|
2755
3403
|
const kept = pruneSlotlockAssistantActions(messages, { ...(stored?.actions ?? {}), ...proposed });
|
|
2756
|
-
await store.
|
|
3404
|
+
await store.putChat(tenantRef, member, chatId, {
|
|
3405
|
+
title,
|
|
2757
3406
|
messages,
|
|
2758
3407
|
added: Object.fromEntries(Object.entries(proposed).filter(([id]) => id in kept)),
|
|
2759
3408
|
keep: Object.keys(kept),
|
|
3409
|
+
// A chat it continued, only while it is still there: deleted during the turn, it stays so.
|
|
3410
|
+
create: stored === null,
|
|
2760
3411
|
}, conversationTtl);
|
|
2761
|
-
return
|
|
3412
|
+
return { ok: true, messages, actions: kept, chatId, title };
|
|
3413
|
+
}
|
|
3414
|
+
/** The form's way (no script): run the turn, then show the conversation at its latest message. */
|
|
3415
|
+
async function sendToAssistant(session, form) {
|
|
3416
|
+
const run = await runAssistantMessage(session, form);
|
|
3417
|
+
if (!run.ok) {
|
|
3418
|
+
return run.status === 404
|
|
3419
|
+
? notFound(session)
|
|
3420
|
+
: assistantPage(session, run.status, run.message, chatOf(form.get('chat')) ?? 'new');
|
|
3421
|
+
}
|
|
3422
|
+
return redirect(303, `${chatUrl(run.chatId)}#latest`);
|
|
3423
|
+
}
|
|
3424
|
+
/**
|
|
3425
|
+
* app.js's way: the same turn, answered as it happens, one JSON object a line. `text` carries a
|
|
3426
|
+
* piece of the reply, `step` a tool step as it starts and ends, and the last line is `done` (the
|
|
3427
|
+
* turn as the page renders it, which replaces what was streamed) or `error`, each with a fresh
|
|
3428
|
+
* single-use value for the next send. The member's Stop aborts the request, and the turn with it.
|
|
3429
|
+
*/
|
|
3430
|
+
async function streamToAssistant(session, form, request) {
|
|
3431
|
+
const encoder = new TextEncoder();
|
|
3432
|
+
// Stop, or a client gone, cancels the body. That stops the turn, which keeps what it had, and
|
|
3433
|
+
// the cancel settles only once it has: a Worker closes the request's connection then, and a
|
|
3434
|
+
// runtime that never aborts request.signal on a disconnect stops the turn all the same.
|
|
3435
|
+
const stop = new AbortController();
|
|
3436
|
+
let open = true;
|
|
3437
|
+
let sink;
|
|
3438
|
+
let work = Promise.resolve();
|
|
3439
|
+
const body = new ReadableStream({
|
|
3440
|
+
start(controller) {
|
|
3441
|
+
sink = controller;
|
|
3442
|
+
},
|
|
3443
|
+
cancel() {
|
|
3444
|
+
open = false;
|
|
3445
|
+
stop.abort();
|
|
3446
|
+
return work;
|
|
3447
|
+
},
|
|
3448
|
+
});
|
|
3449
|
+
const write = (event) => {
|
|
3450
|
+
if (!open)
|
|
3451
|
+
return;
|
|
3452
|
+
try {
|
|
3453
|
+
sink?.enqueue(encoder.encode(`${JSON.stringify(event)}\n`));
|
|
3454
|
+
}
|
|
3455
|
+
catch {
|
|
3456
|
+
open = false;
|
|
3457
|
+
}
|
|
3458
|
+
};
|
|
3459
|
+
const label = (name, state) => slotlockAssistantStepLabel(name, toolTitles.get(name) ?? name, state, SLOTLOCK_ASSISTANT_TOOL_USE[name] === 'propose');
|
|
3460
|
+
const failed = (message) => write({
|
|
3461
|
+
t: 'error',
|
|
3462
|
+
html: html `<li class="turn error" role="alert"><span>${message}</span></li>`.value,
|
|
3463
|
+
once: formNonce(session),
|
|
3464
|
+
});
|
|
3465
|
+
work = (async () => {
|
|
3466
|
+
try {
|
|
3467
|
+
const run = await runAssistantMessage(session, form, {
|
|
3468
|
+
signal: AbortSignal.any([request.signal, stop.signal]),
|
|
3469
|
+
onEvent: (event) => {
|
|
3470
|
+
if (event.type === 'text')
|
|
3471
|
+
write({ t: 'text', d: event.delta });
|
|
3472
|
+
else if (event.type === 'call')
|
|
3473
|
+
write({ t: 'step', id: event.id, s: 'run', label: label(event.name, 'run') });
|
|
3474
|
+
else
|
|
3475
|
+
write({ t: 'step', id: event.id, s: event.ok ? 'ok' : 'bad', label: label(event.name, event.ok ? 'ok' : 'bad') });
|
|
3476
|
+
},
|
|
3477
|
+
});
|
|
3478
|
+
if (!run.ok) {
|
|
3479
|
+
failed(run.message);
|
|
3480
|
+
return;
|
|
3481
|
+
}
|
|
3482
|
+
// The turn from the member's message on, as a reload would show it.
|
|
3483
|
+
const start = run.messages.map((entry) => entry.role).lastIndexOf('user');
|
|
3484
|
+
const turn = run.messages.slice(Math.max(0, start));
|
|
3485
|
+
const lookups = Object.keys(run.actions).length > 0
|
|
3486
|
+
? await resourceLookups(tenantOf(session))
|
|
3487
|
+
: { names: new Map(), zones: new Map() };
|
|
3488
|
+
const chats = await assistantStore()?.listChats(tenantOf(session), personOf(session));
|
|
3489
|
+
write({
|
|
3490
|
+
t: 'done',
|
|
3491
|
+
html: assistantItems(session, turn, run.actions, lookups.names, lookups.zones, run.chatId).join(''),
|
|
3492
|
+
once: formNonce(session),
|
|
3493
|
+
// The conversation it went to (a new one has its id now), its address, and the list again.
|
|
3494
|
+
chat: run.chatId,
|
|
3495
|
+
title: run.title,
|
|
3496
|
+
url: chatUrl(run.chatId),
|
|
3497
|
+
chats: chats ? chatsPanel(session, chats, run.chatId).value : undefined,
|
|
3498
|
+
});
|
|
3499
|
+
}
|
|
3500
|
+
catch (error) {
|
|
3501
|
+
report(error instanceof Error ? error.message : 'assistant stream failed');
|
|
3502
|
+
failed('Something went wrong. Reload the page and try again.');
|
|
3503
|
+
}
|
|
3504
|
+
finally {
|
|
3505
|
+
if (open) {
|
|
3506
|
+
open = false;
|
|
3507
|
+
try {
|
|
3508
|
+
sink?.close();
|
|
3509
|
+
}
|
|
3510
|
+
catch {
|
|
3511
|
+
// Cancelled meanwhile.
|
|
3512
|
+
}
|
|
3513
|
+
}
|
|
3514
|
+
}
|
|
3515
|
+
})();
|
|
3516
|
+
ignoreRejection(work);
|
|
3517
|
+
const page = respond(200, '');
|
|
3518
|
+
const headers = new Headers(page.headers);
|
|
3519
|
+
headers.set('Content-Type', 'application/x-ndjson; charset=utf-8');
|
|
3520
|
+
// Proxies that buffer (nginx) send each line as it is written.
|
|
3521
|
+
headers.set('X-Accel-Buffering', 'no');
|
|
3522
|
+
return new Response(body, { status: 200, headers });
|
|
2762
3523
|
}
|
|
2763
3524
|
async function settleAction(session, form, decision) {
|
|
2764
3525
|
const store = assistantStore();
|
|
2765
3526
|
if (!store || !assistantOptions)
|
|
2766
3527
|
return notFound(session);
|
|
2767
3528
|
const id = form.get('action') ?? '';
|
|
3529
|
+
const chatId = await chatOfForm(store, session, form, true);
|
|
2768
3530
|
const tenantRef = tenantOf(session);
|
|
2769
3531
|
const member = personOf(session);
|
|
2770
|
-
const claimed = ACTION_ID.test(id)
|
|
2771
|
-
? await store.settleAction(tenantRef, member, id, 'pending', decision === 'approve' ? { status: 'approved' } : { status: 'dismissed', input: {} })
|
|
3532
|
+
const claimed = ACTION_ID.test(id) && chatId
|
|
3533
|
+
? await store.settleAction(tenantRef, member, chatId, id, 'pending', decision === 'approve' ? { status: 'approved' } : { status: 'dismissed', input: {} })
|
|
2772
3534
|
: null;
|
|
2773
3535
|
const action = claimed ? readAction(id, claimed) : null;
|
|
2774
|
-
if (!action)
|
|
2775
|
-
return assistantPage(session, 409, 'That change was already approved or dismissed.');
|
|
3536
|
+
if (!action || !chatId) {
|
|
3537
|
+
return assistantPage(session, 409, 'That change was already approved or dismissed.', chatId ?? undefined);
|
|
3538
|
+
}
|
|
2776
3539
|
const title = toolTitles.get(action.operation) ?? action.operation;
|
|
2777
3540
|
let event;
|
|
2778
3541
|
if (decision === 'dismiss') {
|
|
2779
3542
|
event = `The member dismissed the proposed change ${id} (${title}); it did not run.`;
|
|
2780
3543
|
}
|
|
2781
3544
|
else if (SLOTLOCK_ASSISTANT_TOOL_USE[action.operation] !== 'propose') {
|
|
2782
|
-
await store.settleAction(tenantRef, member, id, 'approved', { status: 'failed', outcome: 'tool_not_available', input: {} });
|
|
3545
|
+
await store.settleAction(tenantRef, member, chatId, id, 'approved', { status: 'failed', outcome: 'tool_not_available', input: {} });
|
|
2783
3546
|
event = `The proposed change ${id} (${title}) could not run: tool_not_available.`;
|
|
2784
3547
|
}
|
|
2785
3548
|
else {
|
|
2786
3549
|
const outcome = await dispatch(session, action.operation, action.input, 'run');
|
|
2787
3550
|
// Settled, the card keeps what it said, not the arguments: the actions stay small.
|
|
2788
|
-
await store.settleAction(tenantRef, member, id, 'approved', outcome.ok ? { status: 'done', input: {} } : { status: 'failed', outcome: outcome.code, input: {} });
|
|
3551
|
+
await store.settleAction(tenantRef, member, chatId, id, 'approved', outcome.ok ? { status: 'done', input: {} } : { status: 'failed', outcome: outcome.code, input: {} });
|
|
2789
3552
|
const result = JSON.stringify(outcome.ok ? outcome.data : { error: { code: outcome.code } });
|
|
2790
3553
|
event = `The member approved the proposed change ${id} (${title}). ${outcome.ok ? 'It ran' : 'It was refused'}: ${result.length > 4_000 ? `${result.slice(0, 4_000)} …[truncated]` : result}`;
|
|
2791
3554
|
}
|
|
2792
|
-
const stored = await store.
|
|
3555
|
+
const stored = await store.getChat(tenantRef, member, chatId);
|
|
2793
3556
|
if (stored) {
|
|
2794
3557
|
const entries = [
|
|
2795
3558
|
...readSlotlockAssistantEntries(stored.messages),
|
|
2796
3559
|
{ role: 'event', text: event },
|
|
2797
3560
|
];
|
|
2798
3561
|
const messages = boundSlotlockAssistantEntries(entries);
|
|
2799
|
-
await store.
|
|
2800
|
-
|
|
2801
|
-
|
|
3562
|
+
await store.putChat(tenantRef, member, chatId, {
|
|
3563
|
+
title: stored.title,
|
|
3564
|
+
messages,
|
|
3565
|
+
added: {},
|
|
3566
|
+
keep: Object.keys(pruneSlotlockAssistantActions(messages, stored.actions)),
|
|
3567
|
+
create: false,
|
|
3568
|
+
}, conversationTtl);
|
|
3569
|
+
}
|
|
3570
|
+
return redirect(303, `${chatUrl(chatId)}#action-${id}`);
|
|
2802
3571
|
}
|
|
3572
|
+
/** A new conversation: the others are kept, in Chats. (A form of an earlier release posts here.) */
|
|
2803
3573
|
async function resetAssistant(session) {
|
|
3574
|
+
if (!assistantStore())
|
|
3575
|
+
return notFound(session);
|
|
3576
|
+
return redirect(303, chatUrl(null));
|
|
3577
|
+
}
|
|
3578
|
+
/** Give one of the member's conversations a title of their own. */
|
|
3579
|
+
async function renameChat(session, form) {
|
|
2804
3580
|
const store = assistantStore();
|
|
2805
3581
|
if (!store)
|
|
2806
3582
|
return notFound(session);
|
|
2807
|
-
|
|
2808
|
-
|
|
3583
|
+
const chatId = chatOf(form.get('chat'));
|
|
3584
|
+
const title = (form.get('title') ?? '').replace(/\s+/gu, ' ').trim();
|
|
3585
|
+
if (!chatId)
|
|
3586
|
+
return assistantPage(session, 404, 'That chat is not here: it was deleted.', 'new');
|
|
3587
|
+
if ([...title].length < 1 || [...title].length > 120) {
|
|
3588
|
+
return assistantPage(session, 400, 'Give the chat a title of 1 to 120 characters.', chatId);
|
|
3589
|
+
}
|
|
3590
|
+
const renamed = await store.renameChat(tenantOf(session), personOf(session), chatId, title);
|
|
3591
|
+
if (!renamed)
|
|
3592
|
+
return assistantPage(session, 404, 'That chat is not here: it was deleted.', 'new');
|
|
3593
|
+
return redirect(303, chatUrl(chatId));
|
|
2809
3594
|
}
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
3595
|
+
/** Delete one of the member's conversations, at once and for good. */
|
|
3596
|
+
async function deleteChat(session, form) {
|
|
3597
|
+
const store = assistantStore();
|
|
3598
|
+
if (!store)
|
|
2813
3599
|
return notFound(session);
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
3600
|
+
const chatId = chatOf(form.get('chat'));
|
|
3601
|
+
if (chatId)
|
|
3602
|
+
await store.deleteChat(tenantOf(session), personOf(session), chatId);
|
|
3603
|
+
return redirect(303, chatUrl(null));
|
|
3604
|
+
}
|
|
3605
|
+
/**
|
|
3606
|
+
* A workspace's plan as both the Usage page and the overview show it: the plan (or the default it
|
|
3607
|
+
* moves to), what it has against the limits, this month's use against the quotas, and what needs a
|
|
3608
|
+
* person: a failed payment, a quota used up or nearly, more than the coming plan allows.
|
|
3609
|
+
* `here` is the page asking, so an item's action points to the page that resolves it.
|
|
3610
|
+
*/
|
|
3611
|
+
async function planStanding(session, here, given) {
|
|
3612
|
+
const active = plans;
|
|
3613
|
+
const billing = options.billing;
|
|
2825
3614
|
const tenantRef = tenantOf(session);
|
|
2826
|
-
const [plan, resourceCount, keys, webhooks] = await Promise.all([
|
|
2827
|
-
|
|
3615
|
+
const [plan, planDefault, resourceCount, keys, webhooks] = await Promise.all([
|
|
3616
|
+
active.plan(tenantRef),
|
|
3617
|
+
active.planDefault ? active.planDefault() : Promise.resolve(null),
|
|
2828
3618
|
// As the limit counts them: archived resources included.
|
|
2829
3619
|
options.resources.count
|
|
2830
3620
|
? options.resources.count(tenantRef)
|
|
2831
3621
|
: options.resources.list(tenantRef).then((listed) => listed.length),
|
|
2832
|
-
options.keys.list({ tenantRef }),
|
|
3622
|
+
given ?? options.keys.list({ tenantRef }),
|
|
2833
3623
|
options.resources.webhooks?.list(tenantRef),
|
|
2834
3624
|
]);
|
|
2835
3625
|
const month = slotlockUsageMonthToDate(now());
|
|
2836
3626
|
const used = options.usage && options.resources.usage
|
|
2837
3627
|
? summarizeSlotlockUsage(await options.resources.usage(tenantRef, month.from, month.to))
|
|
2838
3628
|
: null;
|
|
2839
|
-
const
|
|
2840
|
-
const
|
|
2841
|
-
const token = csrfToken(session);
|
|
3629
|
+
const keyCount = keys.filter((key) => !key.revokedAt && (key.expiresAt === null || key.expiresAt.getTime() > now())).length;
|
|
3630
|
+
const admin = isAdmin(session);
|
|
2842
3631
|
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.
|
|
3632
|
+
const upcoming = !plan && planDefault && planDefault.appliesFrom.getTime() > now() ? planDefault : null;
|
|
3633
|
+
const limits = plan?.limits ?? upcoming?.limits;
|
|
2850
3634
|
const quotas = options.quotas && plan ? SLOTLOCK_PLAN_QUOTAS[plan.plan] : null;
|
|
2851
3635
|
const resets = formatDate(slotlockQuotaPeriod(now()).resetsAt);
|
|
2852
|
-
const
|
|
2853
|
-
|
|
3636
|
+
const counted = (value) => value.toLocaleString('en-GB');
|
|
3637
|
+
const usagePage = `${root}/plan`;
|
|
3638
|
+
const upgradeAt = here === 'usage' ? '#upgrade' : `${usagePage}#upgrade`;
|
|
3639
|
+
const canUpgrade = !paying && admin && Boolean(billing || active.contact);
|
|
3640
|
+
const upgradeHint = paying ? '' : admin ? ' Upgrade for more.' : ' An owner of this organization can upgrade.';
|
|
3641
|
+
const attention = [];
|
|
3642
|
+
// Whether anything is near or past a limit: the overview then offers the upgrade.
|
|
3643
|
+
let pressing = false;
|
|
3644
|
+
if (plan?.status === 'past_due') {
|
|
3645
|
+
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));
|
|
3646
|
+
}
|
|
3647
|
+
if (quotas && used) {
|
|
3648
|
+
for (const metric of ['operations', 'bookings']) {
|
|
2854
3649
|
const quota = quotas[metric];
|
|
2855
3650
|
const stop = Math.floor((quota * quotas.stopPercent) / 100);
|
|
2856
3651
|
const spent = used.totals[metric];
|
|
2857
|
-
const what = metric === 'operations' ? 'operations' : 'bookings';
|
|
2858
3652
|
const refused = metric === 'operations' ? 'every call but reading usage' : 'new bookings and holds';
|
|
3653
|
+
const upgrade = canUpgrade ? { href: upgradeAt, label: 'Upgrade' } : here === 'overview' ? { href: usagePage, label: 'Usage' } : undefined;
|
|
3654
|
+
if (spent * 100 >= quota * 80)
|
|
3655
|
+
pressing = true;
|
|
2859
3656
|
if (spent >= stop) {
|
|
2860
|
-
|
|
3657
|
+
attention.push(attentionItem('bad', `This month's ${metric} are used up: Slotlock refuses ${refused} with quota_exceeded until ${resets}.${upgradeHint}`, upgrade));
|
|
2861
3658
|
}
|
|
2862
|
-
if (spent >= quota) {
|
|
2863
|
-
const billed = metric === 'operations' && billing
|
|
2864
|
-
|
|
3659
|
+
else if (spent >= quota) {
|
|
3660
|
+
const billed = metric === 'operations' && billing?.overage && plan?.plan === 'developer' && paying;
|
|
3661
|
+
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
3662
|
}
|
|
2866
|
-
if (spent * 100 >= quota * 80) {
|
|
2867
|
-
|
|
3663
|
+
else if (spent * 100 >= quota * 80) {
|
|
3664
|
+
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
3665
|
}
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
3666
|
+
}
|
|
3667
|
+
}
|
|
3668
|
+
if (upcoming) {
|
|
3669
|
+
// What the workspace has that its coming plan will not allow: nothing is removed; adding stops.
|
|
3670
|
+
const over = [
|
|
3671
|
+
['resources', resourceCount, upcoming.limits.resources],
|
|
3672
|
+
['active API keys', keyCount, upcoming.limits.apiKeys],
|
|
3673
|
+
['webhooks', webhooks?.length ?? 0, upcoming.limits.webhookEndpoints],
|
|
3674
|
+
].filter(([, have, allowed]) => have > allowed);
|
|
3675
|
+
if (over.length > 0)
|
|
3676
|
+
pressing = true;
|
|
3677
|
+
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
|
|
3678
|
+
? ` 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'}.`
|
|
3679
|
+
: ''}`, canUpgrade && over.length > 0 ? { href: upgradeAt, label: 'See plans' } : undefined));
|
|
3680
|
+
}
|
|
3681
|
+
/** One use against its allowance: a bar, amber from 80%, red at the limit. */
|
|
3682
|
+
const meter = (label, value, limit) => {
|
|
3683
|
+
if (limit === undefined) {
|
|
3684
|
+
return html `<div class="meter"><div class="meter-head"><span>${label}</span><span class="meter-value">${counted(value)}</span></div></div>`;
|
|
3685
|
+
}
|
|
3686
|
+
const tone = value >= limit ? ' bad' : value * 100 >= limit * 80 ? ' warn' : '';
|
|
3687
|
+
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>`;
|
|
3688
|
+
};
|
|
3689
|
+
const limitMeters = html `${meter('Resources', resourceCount, limits?.resources)}${meter('Active API keys', keyCount, limits?.apiKeys)}${webhooks ? meter('Webhooks', webhooks.length, limits?.webhookEndpoints) : html ``}`;
|
|
3690
|
+
// Under a "This month" heading, or beside the limits with the month in each label.
|
|
3691
|
+
const monthMeters = (suffix) => used
|
|
3692
|
+
? html `${meter(`Operations${suffix}`, used.totals.operations, quotas?.operations)}${meter(`Bookings${suffix}`, used.totals.bookings, quotas?.bookings)}${meter(`Holds${suffix}`, used.totals.holds, undefined)}`
|
|
3693
|
+
: html ``;
|
|
3694
|
+
const name = plan
|
|
3695
|
+
? PLAN_NAMES[plan.plan]
|
|
3696
|
+
: upcoming
|
|
3697
|
+
? `${PLAN_NAMES[upcoming.plan]} from ${dayOf(upcoming.appliesFrom)}`
|
|
3698
|
+
: 'No plan';
|
|
3699
|
+
return {
|
|
3700
|
+
plan,
|
|
3701
|
+
upcoming,
|
|
3702
|
+
paying,
|
|
3703
|
+
admin,
|
|
3704
|
+
used,
|
|
3705
|
+
month,
|
|
3706
|
+
resets,
|
|
3707
|
+
keys,
|
|
3708
|
+
name,
|
|
3709
|
+
limitMeters,
|
|
3710
|
+
monthMeters,
|
|
3711
|
+
meters: html `${limitMeters}${monthMeters(' this month')}`,
|
|
3712
|
+
attention,
|
|
3713
|
+
canUpgrade,
|
|
3714
|
+
pressing,
|
|
3715
|
+
};
|
|
3716
|
+
}
|
|
3717
|
+
/** One thing that needs a person: a sentence, its tone, and what resolves it. */
|
|
3718
|
+
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>`;
|
|
3719
|
+
/** The list of things that need a person, or nothing. */
|
|
3720
|
+
const attentionList = (items) => items.length > 0 ? html `<ul class="attention-list" aria-label="Needs attention">${items}</ul>` : html ``;
|
|
3721
|
+
async function planPage(session, status = 200, notice, thanks = false) {
|
|
3722
|
+
if (!plans)
|
|
3723
|
+
return notFound(session);
|
|
3724
|
+
const billing = options.billing;
|
|
3725
|
+
const counted = (value) => value.toLocaleString('en-GB');
|
|
3726
|
+
if (session.env === 'test') {
|
|
3727
|
+
// Its own use, counted apart from Live's and never billed.
|
|
3728
|
+
const range = slotlockUsageMonthToDate(now());
|
|
3729
|
+
const tested = options.usage && options.resources.usage
|
|
3730
|
+
? summarizeSlotlockUsage(await options.resources.usage(tenantOf(session), range.from, range.to))
|
|
3731
|
+
: null;
|
|
3732
|
+
const meter = (label, value) => html `<div class="meter"><div class="meter-head"><span>${label}</span><span class="meter-value">${counted(value)}</span></div></div>`;
|
|
3733
|
+
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
|
|
3734
|
+
? 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>`
|
|
3735
|
+
: html ``}`, session, 'plan'));
|
|
3736
|
+
}
|
|
3737
|
+
const standing = await planStanding(session, 'usage');
|
|
3738
|
+
const { plan, paying, admin, used, month, resets, keys } = standing;
|
|
3739
|
+
const token = csrfToken(session);
|
|
3740
|
+
const keyName = new Map(keys.map((key) => [key.id, key.name]));
|
|
3741
|
+
// An organization is offered Team (where billing sells it, or by asking); a person, Developer.
|
|
3742
|
+
const offered = session.org !== undefined && (billing ? Boolean(billing.teamPrices) : true) ? 'team' : 'developer';
|
|
3743
|
+
const contact = plans.contact ?? billing?.contact;
|
|
3744
|
+
const manage = billing && plan?.stripeCustomer && admin
|
|
3745
|
+
? 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>`
|
|
3746
|
+
: html ``;
|
|
3747
|
+
const renews = paying && plan?.currentPeriodEnd
|
|
3748
|
+
? html `<span class="meta">${plan.status === 'past_due' ? 'Payment due since' : 'Renews'} ${formatDate(plan.currentPeriodEnd)}</span>`
|
|
3749
|
+
: html ``;
|
|
3750
|
+
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
|
|
3751
|
+
? 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>`
|
|
3752
|
+
: html ``}`;
|
|
3753
|
+
const byKey = used && used.keys.length > 0
|
|
3754
|
+
? 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>`
|
|
3755
|
+
: html ``;
|
|
3756
|
+
const offeredLimits = SLOTLOCK_PLAN_LIMITS[offered];
|
|
3757
|
+
const offeredText = html `${String(offeredLimits.resources)} resources, ${String(offeredLimits.apiKeys)} API keys and ${String(offeredLimits.webhookEndpoints)} webhook endpoints${options.quotas
|
|
3758
|
+
? 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' : ''}`
|
|
3759
|
+
: html ``}.`;
|
|
3760
|
+
let upgrade = html ``;
|
|
3761
|
+
if (!paying) {
|
|
3762
|
+
if (!admin) {
|
|
3763
|
+
upgrade = html `<p class="fine">An owner of this organization can change its plan.</p>`;
|
|
3764
|
+
}
|
|
3765
|
+
else if (billing) {
|
|
3766
|
+
const offeredPrices = offered === 'team' ? billing.teamPrices : billing.prices;
|
|
3767
|
+
upgrade = html `<section class="panel" id="upgrade">
|
|
2880
3768
|
<h2>${PLAN_NAMES[offered]}</h2>
|
|
2881
|
-
<p>${
|
|
2882
|
-
|
|
2883
|
-
|
|
3769
|
+
<p>${offeredText}${offered === 'developer' && billing.overage && options.quotas
|
|
3770
|
+
? ` Operations past them are billed at ${billing.overage.price}, up to ${String(billing.overage.stopPercent)}% of them.`
|
|
3771
|
+
: ''}</p>
|
|
2884
3772
|
<form method="post" action="${root}/plan/upgrade">
|
|
2885
3773
|
<input type="hidden" name="csrf" value="${token}">
|
|
2886
3774
|
<input type="hidden" name="once" value="${formNonce(session)}">
|
|
@@ -2892,46 +3780,22 @@ ${settings
|
|
|
2892
3780
|
</form>
|
|
2893
3781
|
<p class="fine">Paid through Stripe, in your currency, with tax added where it applies. Cancel at any time.</p>
|
|
2894
3782
|
</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>`}
|
|
3783
|
+
}
|
|
3784
|
+
else if (contact) {
|
|
3785
|
+
// No payment here yet: the plan is arranged by asking.
|
|
3786
|
+
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>`;
|
|
3787
|
+
}
|
|
3788
|
+
}
|
|
3789
|
+
const content = html `${thanks ? html `<p class="notice done" role="status">${notice ?? ''}</p>` : noticeOf(notice, status)}
|
|
3790
|
+
${pageHead('Usage', manage)}
|
|
3791
|
+
${attentionList(standing.attention)}
|
|
3792
|
+
${planCard}
|
|
3793
|
+
${byKey}
|
|
2928
3794
|
${upgrade}
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
</section>`;
|
|
2934
|
-
return respond(status, layout('Plan', content, session, 'plan'));
|
|
3795
|
+
${contact
|
|
3796
|
+
? 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>`
|
|
3797
|
+
: html ``}`;
|
|
3798
|
+
return respond(status, layout('Usage', content, session, 'plan'));
|
|
2935
3799
|
}
|
|
2936
3800
|
async function upgradePlan(session, form) {
|
|
2937
3801
|
const billing = options.billing;
|
|
@@ -3001,25 +3865,54 @@ ${upgrade}
|
|
|
3001
3865
|
if ((route.startsWith('/locations') || route.startsWith('/groups')) && options.resources.grouping) {
|
|
3002
3866
|
return locationsPage;
|
|
3003
3867
|
}
|
|
3868
|
+
if (route.startsWith('/closures') && options.resources.hours)
|
|
3869
|
+
return closuresPage;
|
|
3870
|
+
if (route.startsWith('/contacts') && options.resources.contacts)
|
|
3871
|
+
return contactsPage;
|
|
3004
3872
|
if (route.startsWith('/resources') || route === '/hours')
|
|
3005
3873
|
return resourcesPage;
|
|
3006
3874
|
if (route.startsWith('/assistant') && assistantStore())
|
|
3007
3875
|
return assistantPage;
|
|
3008
3876
|
if (route.startsWith('/settings'))
|
|
3009
3877
|
return settingsPage;
|
|
3010
|
-
if (route.startsWith('/plan') &&
|
|
3878
|
+
if (route.startsWith('/plan') && plans)
|
|
3011
3879
|
return planPage;
|
|
3012
3880
|
if (route.startsWith('/teams') && teams)
|
|
3013
3881
|
return teamsPage;
|
|
3014
3882
|
return overviewPage;
|
|
3015
3883
|
}
|
|
3884
|
+
const isLocationTarget = (target) => typeof target === 'object' && 'location' in target && typeof target.location === 'object';
|
|
3885
|
+
/** How many resources keep hours of their own, a few by name, and what saving does to them. */
|
|
3886
|
+
function ownHoursChoice(own) {
|
|
3887
|
+
if (own.length === 0)
|
|
3888
|
+
return html ``;
|
|
3889
|
+
const named = own.slice(0, OWN_HOURS_NAMED);
|
|
3890
|
+
const word = own.length === 1 ? 'resource keeps hours of its own' : 'resources keep hours of their own';
|
|
3891
|
+
return html `<fieldset class="modes"><legend>${String(own.length)} ${word}</legend>
|
|
3892
|
+
<p class="fine">${named.map((resource, index) => html `${index > 0 ? ', ' : ''}<a href="${root}/hours?resource=${resource.id}">${resourceName(resource, resource.id)}</a>`)}${own.length > named.length ? html ` and ${String(own.length - named.length)} more` : html ``}. <a href="${root}/resources?hours=own">See them all</a></p>
|
|
3893
|
+
<label class="choice"><input type="radio" name="override" value="none" checked> Keep them</label>
|
|
3894
|
+
<label class="choice"><input type="radio" name="override" value="matching"> Clear those with the same hours as yours were, or as these: copies an earlier version made when your hours were set</label>
|
|
3895
|
+
<label class="choice"><input type="radio" name="override" value="all"> Clear them all, so every resource uses these</label>
|
|
3896
|
+
</fieldset>`;
|
|
3897
|
+
}
|
|
3016
3898
|
function hoursPage(session, state) {
|
|
3017
3899
|
const { target, mode } = state;
|
|
3018
3900
|
const checked = (value) => (mode === value ? html ` checked` : html ``);
|
|
3019
3901
|
const server = options.availability ?? [];
|
|
3902
|
+
const place = isLocationTarget(target) ? target.location : null;
|
|
3903
|
+
const resource = target !== 'all' && !place ? target : null;
|
|
3020
3904
|
const title = target === 'all'
|
|
3021
|
-
? '
|
|
3022
|
-
:
|
|
3905
|
+
? 'Your hours'
|
|
3906
|
+
: place
|
|
3907
|
+
? `Hours at ${place.name}`
|
|
3908
|
+
: `Hours for ${resource?.externalRef ?? resource?.id ?? ''}`;
|
|
3909
|
+
const inheritLabel = target === 'all'
|
|
3910
|
+
? html `The server's hours (${server.length === 0 ? 'none' : hoursSummary(server)})`
|
|
3911
|
+
: place
|
|
3912
|
+
? html `Inherit: a location it is in, else your hours, else the server's`
|
|
3913
|
+
: resource && resource.availabilityRules === undefined
|
|
3914
|
+
? html `Inherit (${resourceHours(resource, server)})`
|
|
3915
|
+
: html `Inherit: its location's hours, else yours, else the server's`;
|
|
3023
3916
|
const rows = WEEKDAYS.map((day) => {
|
|
3024
3917
|
const cells = state.week[day].flatMap(({ from, to }, index) => {
|
|
3025
3918
|
const which = index + 1;
|
|
@@ -3033,30 +3926,50 @@ ${upgrade}
|
|
|
3033
3926
|
});
|
|
3034
3927
|
const content = html `${state.notice ? html `<p class="notice" role="alert">${state.notice}</p>` : html ``}
|
|
3035
3928
|
<section>
|
|
3036
|
-
<p><a class="back" href="${root}
|
|
3929
|
+
<p><a class="back" href="${root}/${place ? 'locations' : 'resources'}">${place ? 'Locations' : 'Resources'}</a></p>
|
|
3037
3930
|
<h1>${title}</h1>
|
|
3038
3931
|
<p>${target === 'all'
|
|
3039
|
-
? '
|
|
3040
|
-
:
|
|
3932
|
+
? 'Every resource without hours of its own uses these, each in its own time zone, unless its location has hours. Resources you add later too.'
|
|
3933
|
+
: place
|
|
3934
|
+
? `Resources at ${place.name}, or at a location inside it, without hours of their own use these, each in its own time zone, unless a location nearer them has hours.`
|
|
3935
|
+
: `When agents may book it, in its time zone: ${resource?.timezone ?? ''}.`}${options.resources.hours ? html ` <a href="${root}/closures">Closures and special days</a> change single days.` : html ``}</p>
|
|
3041
3936
|
${state.unshown
|
|
3042
3937
|
? html `<div class="notice warn" role="alert"><p>These hours were set outside the dashboard, so the week below cannot show them. Saving replaces them.</p><ul class="hours">${state.unshown.map((rule) => html `<li><code>${rule.rrule}</code> from ${formatMinutes(rule.startMinutes)} for ${String(rule.durationMinutes)} min</li>`)}</ul></div>`
|
|
3043
3938
|
: html ``}
|
|
3044
3939
|
<form method="post" action="${root}/hours">
|
|
3045
3940
|
<input type="hidden" name="csrf" value="${csrfToken(session)}">
|
|
3046
3941
|
<input type="hidden" name="once" value="${formNonce(session)}">
|
|
3047
|
-
|
|
3942
|
+
${place ? html `<input type="hidden" name="location" value="${place.ref}">` : html `<input type="hidden" name="resource" value="${target === 'all' ? 'all' : (resource?.id ?? '')}">`}
|
|
3048
3943
|
<fieldset class="modes"><legend>Bookable hours</legend>
|
|
3049
|
-
<label class="choice"><input type="radio" name="mode" value="server"${checked('server')}>
|
|
3944
|
+
<label class="choice"><input type="radio" name="mode" value="server"${checked('server')}> ${inheritLabel}</label>
|
|
3050
3945
|
<label class="choice"><input type="radio" name="mode" value="closed"${checked('closed')}> Closed: agents find no free slot</label>
|
|
3051
3946
|
<label class="choice"><input type="radio" name="mode" value="custom"${checked('custom')}> These hours</label>
|
|
3052
3947
|
</fieldset>
|
|
3053
3948
|
<div class="table"><table class="week"><thead><tr><th>Day</th><th>Opens</th><th>Closes</th><th>Opens</th><th>Closes</th></tr></thead><tbody>${rows}</tbody></table></div>
|
|
3054
3949
|
<p class="fine">A closing time at or before the opening time closes the next day; 00:00 to 00:00 is the whole day. Leave a window empty for none.</p>
|
|
3950
|
+
${target === 'all' ? ownHoursChoice(state.own ?? []) : html ``}
|
|
3055
3951
|
<button class="primary" type="submit">Save hours</button>
|
|
3056
3952
|
</form>
|
|
3057
3953
|
</section>`;
|
|
3058
3954
|
return respond(state.status ?? 200, layout('Bookable hours', content, session, 'resources'));
|
|
3059
3955
|
}
|
|
3956
|
+
/** The editor's state for stored rules: inherit (null), closed ([]), or a week. */
|
|
3957
|
+
function hoursState(rules) {
|
|
3958
|
+
if (rules === undefined || rules === null) {
|
|
3959
|
+
return { mode: 'server', week: weekFormOf(rulesToWeeklyHours(options.availability ?? [])) };
|
|
3960
|
+
}
|
|
3961
|
+
if (rules.length === 0)
|
|
3962
|
+
return { mode: 'closed', week: emptyWeekForm() };
|
|
3963
|
+
const week = rulesToWeeklyHours(rules);
|
|
3964
|
+
return { mode: 'custom', week: weekFormOf(week), ...(week === null ? { unshown: rules } : {}) };
|
|
3965
|
+
}
|
|
3966
|
+
/** The tenant's location with that reference, or null. */
|
|
3967
|
+
async function ownLocation(session, ref) {
|
|
3968
|
+
const grouping = options.resources.grouping;
|
|
3969
|
+
if (!grouping || !/^[a-z0-9][a-z0-9._-]{0,99}$/.test(ref))
|
|
3970
|
+
return null;
|
|
3971
|
+
return (await grouping.locations(tenantOf(session))).find((location) => location.ref === ref) ?? null;
|
|
3972
|
+
}
|
|
3060
3973
|
/** The tenant's resource with that id, or null for anything else. */
|
|
3061
3974
|
async function ownResource(session, id) {
|
|
3062
3975
|
if (!UUID_PATTERN.test(id))
|
|
@@ -3066,38 +3979,51 @@ ${state.unshown
|
|
|
3066
3979
|
}
|
|
3067
3980
|
const notFound = (session) => respond(404, layout('Not found', html `<p class="notice">That resource is not one of yours.</p>`, session));
|
|
3068
3981
|
const nothingHere = (session) => respond(404, layout('Not found', html `<p class="notice">Nothing is here.</p>`, session));
|
|
3982
|
+
/** The tenant's resources in use that keep hours of their own, by name. */
|
|
3983
|
+
async function ownHoursOf(session) {
|
|
3984
|
+
const resources = await options.resources.list(tenantOf(session));
|
|
3985
|
+
return resources
|
|
3986
|
+
.filter((resource) => resource.archivedAt === undefined && resource.availabilityRules !== undefined)
|
|
3987
|
+
.sort((one, other) => resourceName(one, one.id).localeCompare(resourceName(other, other.id)));
|
|
3988
|
+
}
|
|
3069
3989
|
async function hoursEditor(session, url) {
|
|
3990
|
+
const ref = url.searchParams.get('location');
|
|
3991
|
+
if (ref !== null) {
|
|
3992
|
+
const location = await ownLocation(session, ref);
|
|
3993
|
+
if (!location || !options.resources.hours)
|
|
3994
|
+
return nothingHere(session);
|
|
3995
|
+
const defaults = await options.resources.hours.defaults(tenantOf(session));
|
|
3996
|
+
const rules = defaults.locations.find((entry) => entry.location === ref)?.rules ?? null;
|
|
3997
|
+
return hoursPage(session, { target: { location }, ...hoursState(rules) });
|
|
3998
|
+
}
|
|
3070
3999
|
const id = url.searchParams.get('resource');
|
|
3071
4000
|
if (id === null) {
|
|
3072
|
-
const
|
|
3073
|
-
return hoursPage(session, { target: 'all',
|
|
4001
|
+
const [defaults, own] = await Promise.all([options.resources.hours?.defaults(tenantOf(session)), ownHoursOf(session)]);
|
|
4002
|
+
return hoursPage(session, { target: 'all', ...hoursState(defaults?.tenant ?? null), own });
|
|
3074
4003
|
}
|
|
3075
4004
|
const resource = await ownResource(session, id);
|
|
3076
4005
|
if (!resource)
|
|
3077
4006
|
return notFound(session);
|
|
3078
|
-
|
|
3079
|
-
if (rules === undefined) {
|
|
3080
|
-
const server = rulesToWeeklyHours(options.availability ?? []);
|
|
3081
|
-
return hoursPage(session, { target: resource, mode: 'server', week: weekFormOf(server) });
|
|
3082
|
-
}
|
|
3083
|
-
if (rules.length === 0) {
|
|
3084
|
-
return hoursPage(session, { target: resource, mode: 'closed', week: emptyWeekForm() });
|
|
3085
|
-
}
|
|
3086
|
-
const week = rulesToWeeklyHours(rules);
|
|
3087
|
-
return hoursPage(session, {
|
|
3088
|
-
target: resource,
|
|
3089
|
-
mode: 'custom',
|
|
3090
|
-
week: weekFormOf(week),
|
|
3091
|
-
...(week === null ? { unshown: rules } : {}),
|
|
3092
|
-
});
|
|
4007
|
+
return hoursPage(session, { target: resource, ...hoursState(resource.availabilityRules) });
|
|
3093
4008
|
}
|
|
4009
|
+
/** The form's choice for resources' own hours (`yes`: the checkbox of a page from before); anything else keeps them. */
|
|
4010
|
+
const overrideOf = (value) => value === 'all' || value === 'yes' ? 'all' : value === 'matching' ? 'matching' : 'none';
|
|
3094
4011
|
async function saveHours(session, form) {
|
|
4012
|
+
const ref = form.get('location');
|
|
3095
4013
|
const id = form.get('resource') ?? '';
|
|
3096
|
-
|
|
4014
|
+
let target;
|
|
4015
|
+
if (ref !== null) {
|
|
4016
|
+
const location = await ownLocation(session, ref);
|
|
4017
|
+
target = location && options.resources.hours ? { location } : null;
|
|
4018
|
+
}
|
|
4019
|
+
else {
|
|
4020
|
+
target = id === 'all' ? 'all' : await ownResource(session, id);
|
|
4021
|
+
}
|
|
3097
4022
|
if (!target)
|
|
3098
4023
|
return notFound(session);
|
|
3099
4024
|
const mode = form.get('mode');
|
|
3100
4025
|
const week = readWeekForm(form);
|
|
4026
|
+
const own = target === 'all' ? await ownHoursOf(session) : undefined;
|
|
3101
4027
|
if (mode !== 'server' && mode !== 'closed' && mode !== 'custom') {
|
|
3102
4028
|
return hoursPage(session, {
|
|
3103
4029
|
target,
|
|
@@ -3105,6 +4031,7 @@ ${state.unshown
|
|
|
3105
4031
|
week,
|
|
3106
4032
|
status: 400,
|
|
3107
4033
|
notice: 'Choose which hours to use.',
|
|
4034
|
+
own,
|
|
3108
4035
|
});
|
|
3109
4036
|
}
|
|
3110
4037
|
let rules = mode === 'server' ? null : [];
|
|
@@ -3126,16 +4053,254 @@ ${state.unshown
|
|
|
3126
4053
|
week,
|
|
3127
4054
|
status: 400,
|
|
3128
4055
|
notice: error.message,
|
|
4056
|
+
own,
|
|
3129
4057
|
});
|
|
3130
4058
|
}
|
|
3131
4059
|
}
|
|
3132
|
-
const
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
4060
|
+
const place = isLocationTarget(target) ? target.location : null;
|
|
4061
|
+
const changed = target === 'all'
|
|
4062
|
+
? await options.resources.setHours(tenantOf(session), 'all', rules, personOf(session), overrideOf(form.get('override')))
|
|
4063
|
+
: await options.resources.setHours(tenantOf(session), place ? { location: place.ref } : { id: target.id }, rules, personOf(session));
|
|
4064
|
+
if (changed === 0 && target !== 'all')
|
|
4065
|
+
return notFound(session);
|
|
4066
|
+
return redirect(303, `${root}/${place ? 'locations' : 'resources'}`);
|
|
4067
|
+
}
|
|
4068
|
+
/** A subscription's address as the page names it: its host, never its path or query (which may hold a token). */
|
|
4069
|
+
const hostOfUrl = (url) => {
|
|
4070
|
+
try {
|
|
4071
|
+
return new URL(url).host;
|
|
3137
4072
|
}
|
|
3138
|
-
|
|
4073
|
+
catch {
|
|
4074
|
+
return 'its address';
|
|
4075
|
+
}
|
|
4076
|
+
};
|
|
4077
|
+
/** The tenant's closures and special days from yesterday on, with forms to add and import more. */
|
|
4078
|
+
async function closuresPage(session, status = 200, notice) {
|
|
4079
|
+
const hours = options.resources.hours;
|
|
4080
|
+
if (!hours)
|
|
4081
|
+
return nothingHere(session);
|
|
4082
|
+
const tenantRef = tenantOf(session);
|
|
4083
|
+
const [exceptions, locations, resources, calendars] = await Promise.all([
|
|
4084
|
+
hours.exceptions(tenantRef, SLOTLOCK_DASHBOARD_MAX_EXCEPTIONS + 1),
|
|
4085
|
+
options.resources.grouping?.locations(tenantRef) ?? Promise.resolve([]),
|
|
4086
|
+
options.resources.list(tenantRef),
|
|
4087
|
+
hours.calendars?.(tenantRef) ?? Promise.resolve([]),
|
|
4088
|
+
]);
|
|
4089
|
+
const token = csrfToken(session);
|
|
4090
|
+
const sent = () => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}">`;
|
|
4091
|
+
const names = new Map(resources.map((resource) => [resource.id, resourceName(resource, resource.id)]));
|
|
4092
|
+
const places = new Map(locations.map((location) => [location.ref, location.name]));
|
|
4093
|
+
const scopeOf = (exception) => exception.resourceId
|
|
4094
|
+
? names.get(exception.resourceId) ?? exception.resourceId
|
|
4095
|
+
: exception.location
|
|
4096
|
+
? `${places.get(exception.location) ?? exception.location} (location)`
|
|
4097
|
+
: 'Everywhere';
|
|
4098
|
+
const days = (exception) => exception.startsOn === exception.endsOn ? exception.startsOn : `${exception.startsOn} to ${exception.endsOn}`;
|
|
4099
|
+
const what = (exception) => exception.outcome === 'closed'
|
|
4100
|
+
? 'Closed'
|
|
4101
|
+
: `Open ${(exception.hours ?? []).map((window) => hoursOf(window.startMinutes, window.durationMinutes)).join(', ')}`;
|
|
4102
|
+
const scopeOptions = html `<option value="">Everywhere</option>${locations.map((location) => html `<option value="location:${location.ref}">${location.name} (location)</option>`)}${resources.slice(0, SLOTLOCK_DASHBOARD_MAX_RESOURCES).map((resource) => html `<option value="resource:${resource.id}">${resourceName(resource, resource.id)}</option>`)}`;
|
|
4103
|
+
const shown = exceptions.slice(0, SLOTLOCK_DASHBOARD_MAX_EXCEPTIONS);
|
|
4104
|
+
const content = html `${noticeOf(notice)}
|
|
4105
|
+
<section>
|
|
4106
|
+
<p><a class="back" href="${root}/resources">Resources</a></p>
|
|
4107
|
+
<h1>Closures and special days</h1>
|
|
4108
|
+
<p>Each closes whole days, or gives them hours of their own instead of the weekly hours, read in each resource's time zone. Where several apply to a day, the nearest wins: a resource's own, then its location's, then everywhere's.</p>
|
|
4109
|
+
<div class="table"><table><thead><tr><th>Days</th><th>Applies to</th><th>Hours</th><th>Label</th><th></th></tr></thead><tbody>${shown.length === 0
|
|
4110
|
+
? html `<tr><td colspan="5" class="muted">No closures from today on.</td></tr>`
|
|
4111
|
+
: shown.map((exception) => html `<tr><td data-label="Days">${days(exception)}</td><td data-label="Applies to">${scopeOf(exception)}</td><td data-label="Hours">${what(exception)}</td><td data-label="Label" class="wrap">${exception.label}${exception.source === 'ics' ? html ` <span class="meta">imported</span>` : html ``}</td><td class="actions"><form method="post" action="${root}/closures/delete">${sent()}<input type="hidden" name="id" value="${exception.id}"><button type="submit">Delete</button></form></td></tr>`)}</tbody></table></div>
|
|
4112
|
+
${exceptions.length > SLOTLOCK_DASHBOARD_MAX_EXCEPTIONS ? html `<p class="fine">The first ${String(SLOTLOCK_DASHBOARD_MAX_EXCEPTIONS)} are shown; the API lists them all.</p>` : html ``}
|
|
4113
|
+
<h2>Add a closure or special day</h2>
|
|
4114
|
+
<form method="post" action="${root}/closures" class="stack">${sent()}
|
|
4115
|
+
<label>Applies to <select name="scope">${scopeOptions}</select></label>
|
|
4116
|
+
<label>First day <input type="date" name="starts_on" required></label>
|
|
4117
|
+
<label>Last day <input type="date" name="ends_on"></label>
|
|
4118
|
+
<fieldset class="modes"><legend>Those days</legend>
|
|
4119
|
+
<label class="choice"><input type="radio" name="outcome" value="closed" checked> Closed</label>
|
|
4120
|
+
<label class="choice"><input type="radio" name="outcome" value="open"> Open only from <input type="time" name="opens" aria-label="Opens"> to <input type="time" name="closes" aria-label="Closes"></label>
|
|
4121
|
+
</fieldset>
|
|
4122
|
+
<label>Label <input name="label" maxlength="200" required placeholder="Christmas Day"></label>
|
|
4123
|
+
<button class="primary" type="submit">Add</button>
|
|
4124
|
+
</form>
|
|
4125
|
+
${calendars.length > 0 ? html `<h2>Holiday calendars</h2>
|
|
4126
|
+
<div class="table"><table><thead><tr><th>Calendar</th><th>Applies to</th><th>From</th><th>Closures</th><th></th></tr></thead><tbody>${calendars.map((calendar) => html `<tr><td data-label="Calendar" class="wrap">${calendar.name}${calendar.lastError ? html `<span class="meta bad" role="status">The last import failed: ${calendar.lastError}</span>` : html ``}</td><td data-label="Applies to">${calendar.resourceId
|
|
4127
|
+
? names.get(calendar.resourceId) ?? calendar.resourceId
|
|
4128
|
+
: calendar.location
|
|
4129
|
+
? `${places.get(calendar.location) ?? calendar.location} (location)`
|
|
4130
|
+
: 'Everywhere'}</td><td data-label="From" class="wrap">${calendar.url
|
|
4131
|
+
? html `${hostOfUrl(calendar.url)}<span class="meta">Imported again every 30 days${calendar.nextImportAt ? html `, next ${calendar.nextImportAt.toISOString().slice(0, 10)}` : html ``}</span>`
|
|
4132
|
+
: html `Uploaded or pasted${calendar.importedAt ? html `<span class="meta">${calendar.importedAt.toISOString().slice(0, 10)}</span>` : html ``}`}</td><td data-label="Closures">${String(calendar.closures)}</td><td class="actions">${hours.deleteCalendar
|
|
4133
|
+
? html `<form method="post" action="${root}/closures/calendars/delete">${sent()}<input type="hidden" name="id" value="${calendar.id}"><button type="submit" class="danger">Delete</button></form>`
|
|
4134
|
+
: html ``}</td></tr>`)}</tbody></table></div>
|
|
4135
|
+
<p class="fine">Deleting a calendar deletes its closures too.</p>` : html ``}
|
|
4136
|
+
<h2>Import a holiday calendar</h2>
|
|
4137
|
+
<p>Upload or paste an iCalendar file of days off${hours.byUrl ? html `, or give its address` : html ``}: each all-day event closes its days for the next two years. A calendar is kept by its name where it applies: importing it again updates its closures and removes the holidays it no longer has.</p>
|
|
4138
|
+
<form method="post" action="${root}/closures/import" class="stack" enctype="multipart/form-data">${sent()}
|
|
4139
|
+
<label>Applies to <select name="scope">${scopeOptions}</select></label>
|
|
4140
|
+
<label>Name <input name="name" maxlength="200" placeholder="Its own name, else its address, else Holidays"></label>
|
|
4141
|
+
<label>File <input type="file" name="icalendar_file" accept=".ics,text/calendar"></label>
|
|
4142
|
+
<label>Or paste it <textarea name="icalendar" rows="6" placeholder="BEGIN:VCALENDAR"></textarea></label>
|
|
4143
|
+
${hours.byUrl ? html `<label>Or its address <input type="url" name="url" maxlength="2000" placeholder="https://example.com/holidays.ics"></label>
|
|
4144
|
+
<label class="choice"><input type="checkbox" name="subscribe" value="on"> Import it again every 30 days</label>` : html ``}
|
|
4145
|
+
<button type="submit">Import</button>
|
|
4146
|
+
</form>
|
|
4147
|
+
</section>`;
|
|
4148
|
+
return respond(status, layout('Closures', content, session, 'resources'));
|
|
4149
|
+
}
|
|
4150
|
+
/**
|
|
4151
|
+
* The people the tenant keeps to tell about bookings (RFC 0004 P3): how to reach each, what they
|
|
4152
|
+
* agreed to and when, and a button that forgets one (their reference leaves every booking too).
|
|
4153
|
+
* Contacts are added by the tenant's own systems, through the API.
|
|
4154
|
+
*/
|
|
4155
|
+
async function contactsPage(session, status = 200, notice, after) {
|
|
4156
|
+
const contacts = options.resources.contacts;
|
|
4157
|
+
if (!contacts)
|
|
4158
|
+
return nothingHere(session);
|
|
4159
|
+
const listed = await contacts.list(tenantOf(session), SLOTLOCK_DASHBOARD_CONTACTS_PAGE + 1, after);
|
|
4160
|
+
const shown = listed.slice(0, SLOTLOCK_DASHBOARD_CONTACTS_PAGE);
|
|
4161
|
+
const token = csrfToken(session);
|
|
4162
|
+
const reach = (contact) => [contact.email, contact.phone ? `SMS ${contact.phone}` : null, contact.whatsapp ? `WhatsApp ${contact.whatsapp}` : null]
|
|
4163
|
+
.filter(Boolean)
|
|
4164
|
+
.join(', ') || 'No way to reach them';
|
|
4165
|
+
const agreed = (contact) => ['email', 'sms', 'whatsapp']
|
|
4166
|
+
.filter((channel) => contact.consent[channel] !== undefined)
|
|
4167
|
+
.map((channel) => `${channel === 'sms' ? 'SMS' : channel === 'whatsapp' ? 'WhatsApp' : 'Email'}: ${contact.consent[channel] ? 'yes' : 'no'}${contact.consentAt[channel] ? ` (${contact.consentAt[channel]?.toISOString().slice(0, 10)})` : ''}`)
|
|
4168
|
+
.join('; ') || 'Not asked';
|
|
4169
|
+
const content = html `${noticeOf(notice)}
|
|
4170
|
+
<section>
|
|
4171
|
+
<p><a class="back" href="${root}/resources">Resources</a></p>
|
|
4172
|
+
<h1>Contacts</h1>
|
|
4173
|
+
<p>The people your bookings name to be told about them, kept by your own systems through the API (<code>PUT /v1/contacts/{contact_ref}</code>). Forgetting one deletes them and takes their reference off every booking, for an erasure request.</p>
|
|
4174
|
+
<table><thead><tr><th>Reference</th><th>Name</th><th>Reach by</th><th>Agreed to</th><th></th></tr></thead><tbody>${shown.length === 0
|
|
4175
|
+
? html `<tr><td colspan="5" class="muted">No contacts yet.</td></tr>`
|
|
4176
|
+
: shown.map((contact) => html `<tr><td data-label="Reference"><code>${contact.ref}</code></td><td data-label="Name">${contact.name ?? ''}</td><td data-label="Reach by" class="wrap">${reach(contact)}</td><td data-label="Agreed to" class="wrap">${agreed(contact)}</td><td class="actions"><form method="post" action="${root}/contacts/forget"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="ref" value="${contact.ref}"><button type="submit" class="danger">Forget</button></form></td></tr>`)}</tbody></table>
|
|
4177
|
+
${listed.length > SLOTLOCK_DASHBOARD_CONTACTS_PAGE ? html `<p><a class="button" href="${root}/contacts?after=${encodeURIComponent(shown.at(-1)?.ref ?? '')}">More</a></p>` : html ``}
|
|
4178
|
+
</section>`;
|
|
4179
|
+
return respond(status, layout('Contacts', content, session, 'resources'));
|
|
4180
|
+
}
|
|
4181
|
+
async function forgetContact(session, form) {
|
|
4182
|
+
const contacts = options.resources.contacts;
|
|
4183
|
+
if (!contacts)
|
|
4184
|
+
return nothingHere(session);
|
|
4185
|
+
const ref = form.get('ref') ?? '';
|
|
4186
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(ref))
|
|
4187
|
+
return contactsPage(session, 400, 'That is not a contact reference.');
|
|
4188
|
+
const forgotten = await contacts.forget(tenantOf(session), ref, personOf(session));
|
|
4189
|
+
if (!forgotten.deleted)
|
|
4190
|
+
return contactsPage(session, 404, 'That contact is not one of yours.');
|
|
4191
|
+
return contactsPage(session, 200, `Forgotten. ${forgotten.bookings} booking${forgotten.bookings === 1 ? '' : 's'} no longer name them.`);
|
|
4192
|
+
}
|
|
4193
|
+
/** `location:<ref>` or `resource:<id>` from a closures form, checked against the tenant's. */
|
|
4194
|
+
async function closureScope(session, value) {
|
|
4195
|
+
if (value === '')
|
|
4196
|
+
return {};
|
|
4197
|
+
if (value.startsWith('location:')) {
|
|
4198
|
+
const location = await ownLocation(session, value.slice(9));
|
|
4199
|
+
return location ? { location: location.ref } : null;
|
|
4200
|
+
}
|
|
4201
|
+
if (value.startsWith('resource:')) {
|
|
4202
|
+
const resource = await ownResource(session, value.slice(9));
|
|
4203
|
+
return resource ? { resourceId: resource.id } : null;
|
|
4204
|
+
}
|
|
4205
|
+
return null;
|
|
4206
|
+
}
|
|
4207
|
+
async function saveClosure(session, form) {
|
|
4208
|
+
const hours = options.resources.hours;
|
|
4209
|
+
if (!hours)
|
|
4210
|
+
return nothingHere(session);
|
|
4211
|
+
const scope = await closureScope(session, form.get('scope') ?? '');
|
|
4212
|
+
if (!scope)
|
|
4213
|
+
return closuresPage(session, 400, 'Choose where it applies.');
|
|
4214
|
+
const outcome = form.get('outcome') === 'open' ? 'open' : 'closed';
|
|
4215
|
+
const startsOn = form.get('starts_on') ?? '';
|
|
4216
|
+
const endsOn = form.get('ends_on') || startsOn;
|
|
4217
|
+
let windows;
|
|
4218
|
+
if (outcome === 'open') {
|
|
4219
|
+
const opens = minutesOf(form.get('opens') ?? '');
|
|
4220
|
+
const closes = minutesOf(form.get('closes') ?? '');
|
|
4221
|
+
if (opens === null || closes === null)
|
|
4222
|
+
return closuresPage(session, 400, 'Give the hours those days open.');
|
|
4223
|
+
windows = [{ startMinutes: opens, durationMinutes: closes > opens ? closes - opens : closes + 1_440 - opens }];
|
|
4224
|
+
}
|
|
4225
|
+
try {
|
|
4226
|
+
await hours.addException(tenantOf(session), {
|
|
4227
|
+
...scope,
|
|
4228
|
+
startsOn,
|
|
4229
|
+
endsOn,
|
|
4230
|
+
outcome,
|
|
4231
|
+
...(windows ? { hours: windows } : {}),
|
|
4232
|
+
label: form.get('label') ?? '',
|
|
4233
|
+
}, personOf(session));
|
|
4234
|
+
}
|
|
4235
|
+
catch (error) {
|
|
4236
|
+
const code = error.code;
|
|
4237
|
+
if (code === 'invalid_availability_exception' || code === 'availability_exceptions_limit_reached') {
|
|
4238
|
+
return closuresPage(session, code === 'invalid_availability_exception' ? 400 : 409, error.message);
|
|
4239
|
+
}
|
|
4240
|
+
throw error;
|
|
4241
|
+
}
|
|
4242
|
+
return redirect(303, `${root}/closures`);
|
|
4243
|
+
}
|
|
4244
|
+
async function deleteClosure(session, form) {
|
|
4245
|
+
const hours = options.resources.hours;
|
|
4246
|
+
if (!hours)
|
|
4247
|
+
return nothingHere(session);
|
|
4248
|
+
const id = form.get('id') ?? '';
|
|
4249
|
+
if (!UUID_PATTERN.test(id) || !(await hours.deleteException(tenantOf(session), id, personOf(session)))) {
|
|
4250
|
+
return closuresPage(session, 404, 'That closure is not one of yours.');
|
|
4251
|
+
}
|
|
4252
|
+
return redirect(303, `${root}/closures`);
|
|
4253
|
+
}
|
|
4254
|
+
async function importClosures(session, form) {
|
|
4255
|
+
const hours = options.resources.hours;
|
|
4256
|
+
if (!hours)
|
|
4257
|
+
return nothingHere(session);
|
|
4258
|
+
const scope = await closureScope(session, form.get('scope') ?? '');
|
|
4259
|
+
if (!scope)
|
|
4260
|
+
return closuresPage(session, 400, 'Choose where it applies.');
|
|
4261
|
+
// An uploaded file, else what was pasted, else (where the server reads them) an address.
|
|
4262
|
+
const text = (form.get('icalendar_file') ?? '').trim() || (form.get('icalendar') ?? '').trim();
|
|
4263
|
+
const url = hours.byUrl ? (form.get('url') ?? '').trim() : '';
|
|
4264
|
+
if ((text === '') === (url === '')) {
|
|
4265
|
+
return closuresPage(session, 400, hours.byUrl ? 'Upload or paste a calendar, or give its address: one of them.' : 'Upload or paste a calendar.');
|
|
4266
|
+
}
|
|
4267
|
+
const name = (form.get('name') ?? '').trim();
|
|
4268
|
+
const source = {
|
|
4269
|
+
...(text !== '' ? { icalendar: text } : { url }),
|
|
4270
|
+
...(name !== '' ? { name } : {}),
|
|
4271
|
+
...(url !== '' && form.get('subscribe') === 'on' ? { subscribe: true } : {}),
|
|
4272
|
+
};
|
|
4273
|
+
try {
|
|
4274
|
+
const result = await hours.importHolidays(tenantOf(session), source, scope, personOf(session));
|
|
4275
|
+
const plural = (count, word) => `${String(count)} ${word}${count === 1 ? '' : 's'}`;
|
|
4276
|
+
return closuresPage(session, 200, `Imported ${plural(result.imported, 'closure')}${result.removed > 0 ? `; removed ${plural(result.removed, 'holiday')} the calendar no longer has` : ''}${result.skipped > 0 ? `; skipped ${plural(result.skipped, 'event')} that were not all-day or not in the next two years` : ''}.`);
|
|
4277
|
+
}
|
|
4278
|
+
catch (error) {
|
|
4279
|
+
const code = error.code;
|
|
4280
|
+
if (code === 'invalid_holiday_calendar' ||
|
|
4281
|
+
code === 'invalid_availability_exception' ||
|
|
4282
|
+
code === 'holiday_calendar_unreachable' ||
|
|
4283
|
+
code === 'holiday_url_not_served' ||
|
|
4284
|
+
code === 'location_not_found' ||
|
|
4285
|
+
code === 'resource_not_found') {
|
|
4286
|
+
return closuresPage(session, 400, error.message);
|
|
4287
|
+
}
|
|
4288
|
+
if (code === 'availability_exceptions_limit_reached' || code === 'holiday_calendars_limit_reached') {
|
|
4289
|
+
return closuresPage(session, 409, error.message);
|
|
4290
|
+
}
|
|
4291
|
+
throw error;
|
|
4292
|
+
}
|
|
4293
|
+
}
|
|
4294
|
+
/** Delete a holiday calendar and the closures it made. */
|
|
4295
|
+
async function deleteHolidayCalendar(session, form) {
|
|
4296
|
+
const hours = options.resources.hours;
|
|
4297
|
+
if (!hours?.deleteCalendar)
|
|
4298
|
+
return nothingHere(session);
|
|
4299
|
+
const id = form.get('id') ?? '';
|
|
4300
|
+
if (!(await hours.deleteCalendar(tenantOf(session), id, personOf(session)))) {
|
|
4301
|
+
return closuresPage(session, 404, 'That calendar is not one of yours.');
|
|
4302
|
+
}
|
|
4303
|
+
return redirect(303, `${root}/closures`);
|
|
3139
4304
|
}
|
|
3140
4305
|
function shownOncePage(session, key, apiKey, rotated) {
|
|
3141
4306
|
const org = session.orgs?.find((candidate) => candidate.id === session.org);
|
|
@@ -3188,7 +4353,7 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
|
|
|
3188
4353
|
: html `<span class="muted">An owner can turn it on</span>`;
|
|
3189
4354
|
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
4355
|
});
|
|
3191
|
-
const content = html `${noticeOf(notice)}${pageHead('Teams')}
|
|
4356
|
+
const content = html `${noticeOf(notice, status)}${pageHead('Teams')}
|
|
3192
4357
|
<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
4358
|
${rows.length === 0
|
|
3194
4359
|
? 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.')
|
|
@@ -3586,9 +4751,11 @@ ${rows.length === 0
|
|
|
3586
4751
|
]);
|
|
3587
4752
|
}
|
|
3588
4753
|
/** The form, `'too_large'` past MAX_FORM_BYTES (read no further), or null for another type. */
|
|
3589
|
-
async function readForm(request, maxBytes = MAX_FORM_BYTES) {
|
|
3590
|
-
const
|
|
3591
|
-
|
|
4754
|
+
async function readForm(request, maxBytes = MAX_FORM_BYTES, multipart = false) {
|
|
4755
|
+
const header = request.headers.get('content-type') ?? '';
|
|
4756
|
+
const type = header.split(';')[0]?.trim();
|
|
4757
|
+
const isMultipart = multipart && type === 'multipart/form-data';
|
|
4758
|
+
if (type !== 'application/x-www-form-urlencoded' && !isMultipart)
|
|
3592
4759
|
return null;
|
|
3593
4760
|
const declared = Number(request.headers.get('content-length'));
|
|
3594
4761
|
if (Number.isFinite(declared) && declared > maxBytes)
|
|
@@ -3609,7 +4776,24 @@ ${rows.length === 0
|
|
|
3609
4776
|
}
|
|
3610
4777
|
chunks.push(value);
|
|
3611
4778
|
}
|
|
3612
|
-
|
|
4779
|
+
if (!isMultipart)
|
|
4780
|
+
return new URLSearchParams(Buffer.concat(chunks).toString('utf8'));
|
|
4781
|
+
// A file field is read as text under its name; nothing else a form sends is kept apart.
|
|
4782
|
+
let data;
|
|
4783
|
+
try {
|
|
4784
|
+
data = await new Response(Buffer.concat(chunks), { headers: { 'content-type': header } }).formData();
|
|
4785
|
+
}
|
|
4786
|
+
catch {
|
|
4787
|
+
return null;
|
|
4788
|
+
}
|
|
4789
|
+
const form = new URLSearchParams();
|
|
4790
|
+
for (const [name, value] of data.entries()) {
|
|
4791
|
+
if (typeof value === 'string')
|
|
4792
|
+
form.append(name, value);
|
|
4793
|
+
else if (value.size > 0)
|
|
4794
|
+
form.append(name, await value.text());
|
|
4795
|
+
}
|
|
4796
|
+
return form;
|
|
3613
4797
|
}
|
|
3614
4798
|
async function createKey(session, form) {
|
|
3615
4799
|
const scopes = KEY_ACCESS[form.get('access') ?? ''];
|
|
@@ -3632,9 +4816,9 @@ ${rows.length === 0
|
|
|
3632
4816
|
}
|
|
3633
4817
|
// Keys limited to part of the tenant come with Developer (RFC 0003 pricing); a server without
|
|
3634
4818
|
// plans allows them.
|
|
3635
|
-
const plan =
|
|
4819
|
+
const plan = plans ? await plans.plan(tenantOf(session)) : null;
|
|
3636
4820
|
if (plan?.plan === 'free') {
|
|
3637
|
-
return keysPage(session, 403, 'Keys limited to locations or groups come with the Developer and Team plans. See
|
|
4821
|
+
return keysPage(session, 403, 'Keys limited to locations or groups come with the Developer and Team plans. See Usage.');
|
|
3638
4822
|
}
|
|
3639
4823
|
}
|
|
3640
4824
|
try {
|
|
@@ -3659,8 +4843,8 @@ ${rows.length === 0
|
|
|
3659
4843
|
return keysPage(session, 400, 'Choose up to 20 locations and 20 groups from the lists.');
|
|
3660
4844
|
}
|
|
3661
4845
|
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
|
|
4846
|
+
return keysPage(session, 409, plans
|
|
4847
|
+
? 'You have as many active keys as your plan allows. Revoke one first, or see Usage.'
|
|
3664
4848
|
: 'You have as many keys as Slotlock allows. Revoke one first.');
|
|
3665
4849
|
}
|
|
3666
4850
|
throw error;
|
|
@@ -3735,7 +4919,7 @@ ${rows.length === 0
|
|
|
3735
4919
|
if (code === 'resource_limit_reached') {
|
|
3736
4920
|
const limit = error.limit;
|
|
3737
4921
|
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
|
|
4922
|
+
? `You have ${String(limit)} resources, archived ones included, as many as your plan allows. Delete one, or see Usage.`
|
|
3739
4923
|
: `You have ${SLOTLOCK_DASHBOARD_MAX_RESOURCES} resources, as many as the dashboard allows.`);
|
|
3740
4924
|
}
|
|
3741
4925
|
if (code === 'invalid_timezone') {
|
|
@@ -3756,7 +4940,13 @@ ${rows.length === 0
|
|
|
3756
4940
|
const session = await readSession(request);
|
|
3757
4941
|
if (!session)
|
|
3758
4942
|
return redirect(303, root);
|
|
3759
|
-
|
|
4943
|
+
// A holiday calendar may be uploaded, or pasted, up to its own limit.
|
|
4944
|
+
const importing = route === '/closures/import';
|
|
4945
|
+
const form = await readForm(request, importing
|
|
4946
|
+
? MAX_IMPORT_FORM_BYTES
|
|
4947
|
+
: route === '/assistant' || route === '/assistant/stream'
|
|
4948
|
+
? MAX_ASSISTANT_FORM_BYTES
|
|
4949
|
+
: MAX_FORM_BYTES, importing);
|
|
3760
4950
|
if (form === 'too_large') {
|
|
3761
4951
|
return respond(413, layout('Refused', html `<p class="notice">That form is too large.</p>`, session));
|
|
3762
4952
|
}
|
|
@@ -3794,12 +4984,17 @@ ${rows.length === 0
|
|
|
3794
4984
|
return pageFor(route)(session, 429, 'Too many forms sent from this account in the last 12 hours. Try again later.');
|
|
3795
4985
|
}
|
|
3796
4986
|
if (!first) {
|
|
4987
|
+
// The first one was taken: a second click, a reload or a resend shows what it did.
|
|
4988
|
+
if (route.startsWith('/assistant')) {
|
|
4989
|
+
const chat = chatOf(form.get('chat'));
|
|
4990
|
+
return redirect(303, chat ? `${chatUrl(chat)}#latest` : `${root}/assistant#latest`);
|
|
4991
|
+
}
|
|
3797
4992
|
return pageFor(route)(session, 409, 'That form was already sent. Reload the page to start again.');
|
|
3798
4993
|
}
|
|
3799
4994
|
}
|
|
3800
4995
|
// A tenant new to plans is put on the default plan before it adds what plans limit.
|
|
3801
4996
|
if (route === '/keys' || route === '/resources' || route === '/webhooks') {
|
|
3802
|
-
await
|
|
4997
|
+
await plans?.enrol?.(tenantOf(session));
|
|
3803
4998
|
}
|
|
3804
4999
|
switch (route) {
|
|
3805
5000
|
case '/sign-out':
|
|
@@ -3817,6 +5012,16 @@ ${rows.length === 0
|
|
|
3817
5012
|
return addResource(session, form);
|
|
3818
5013
|
case '/hours':
|
|
3819
5014
|
return saveHours(session, form);
|
|
5015
|
+
case '/closures':
|
|
5016
|
+
return saveClosure(session, form);
|
|
5017
|
+
case '/closures/delete':
|
|
5018
|
+
return deleteClosure(session, form);
|
|
5019
|
+
case '/closures/import':
|
|
5020
|
+
return importClosures(session, form);
|
|
5021
|
+
case '/closures/calendars/delete':
|
|
5022
|
+
return deleteHolidayCalendar(session, form);
|
|
5023
|
+
case '/contacts/forget':
|
|
5024
|
+
return forgetContact(session, form);
|
|
3820
5025
|
case '/organization':
|
|
3821
5026
|
return switchOrganization(session, form);
|
|
3822
5027
|
case '/environment':
|
|
@@ -3857,12 +5062,18 @@ ${rows.length === 0
|
|
|
3857
5062
|
return sandboxAction(session, form, 'fast-forward');
|
|
3858
5063
|
case '/assistant':
|
|
3859
5064
|
return sendToAssistant(session, form);
|
|
5065
|
+
case '/assistant/stream':
|
|
5066
|
+
return streamToAssistant(session, form, request);
|
|
3860
5067
|
case '/assistant/approve':
|
|
3861
5068
|
return settleAction(session, form, 'approve');
|
|
3862
5069
|
case '/assistant/dismiss':
|
|
3863
5070
|
return settleAction(session, form, 'dismiss');
|
|
3864
5071
|
case '/assistant/reset':
|
|
3865
5072
|
return resetAssistant(session);
|
|
5073
|
+
case '/assistant/rename':
|
|
5074
|
+
return renameChat(session, form);
|
|
5075
|
+
case '/assistant/delete':
|
|
5076
|
+
return deleteChat(session, form);
|
|
3866
5077
|
case '/settings/assistant':
|
|
3867
5078
|
return saveAssistantSettings(session, form);
|
|
3868
5079
|
case '/settings/assistant/models':
|
|
@@ -3904,8 +5115,12 @@ ${rows.length === 0
|
|
|
3904
5115
|
case '/waitlist':
|
|
3905
5116
|
case '/approvals':
|
|
3906
5117
|
case '/assistant':
|
|
5118
|
+
case '/assistant/chats':
|
|
3907
5119
|
case '/plan':
|
|
3908
5120
|
case '/teams':
|
|
5121
|
+
case '/settings/endpoints':
|
|
5122
|
+
case '/settings/hours':
|
|
5123
|
+
case '/settings/account':
|
|
3909
5124
|
case '/settings': {
|
|
3910
5125
|
const session = await readSession(request);
|
|
3911
5126
|
if (!session)
|
|
@@ -3919,8 +5134,9 @@ ${rows.length === 0
|
|
|
3919
5134
|
}
|
|
3920
5135
|
if (route === '/keys')
|
|
3921
5136
|
return await keysPage(session);
|
|
3922
|
-
if (route === '/resources')
|
|
3923
|
-
return await resourcesPage(session);
|
|
5137
|
+
if (route === '/resources') {
|
|
5138
|
+
return await resourcesPage(session, 200, undefined, url.searchParams.get('hours') === 'own');
|
|
5139
|
+
}
|
|
3924
5140
|
if (route === '/webhooks')
|
|
3925
5141
|
return await webhooksPage(session);
|
|
3926
5142
|
if (route === '/waitlist')
|
|
@@ -3928,15 +5144,23 @@ ${rows.length === 0
|
|
|
3928
5144
|
if (route === '/approvals')
|
|
3929
5145
|
return await approvalsPage(session);
|
|
3930
5146
|
if (route === '/assistant')
|
|
3931
|
-
return await assistantPage(session);
|
|
5147
|
+
return await assistantPage(session, 200, undefined, url.searchParams.get('c') ?? undefined);
|
|
5148
|
+
if (route === '/assistant/chats')
|
|
5149
|
+
return await chatsPage(session);
|
|
5150
|
+
if (route === '/settings/endpoints')
|
|
5151
|
+
return await settingsPage(session, 200, undefined, undefined, 'endpoints');
|
|
5152
|
+
if (route === '/settings/hours')
|
|
5153
|
+
return await settingsPage(session, 200, undefined, undefined, 'hours');
|
|
5154
|
+
if (route === '/settings/account')
|
|
5155
|
+
return await settingsPage(session, 200, undefined, undefined, 'account');
|
|
3932
5156
|
// A provider preset fills the assistant's form; nothing is saved until Save.
|
|
3933
5157
|
const preset = SLOTLOCK_ASSISTANT_PRESETS.find((candidate) => candidate.id === url.searchParams.get('preset'));
|
|
3934
5158
|
return await settingsPage(session, 200, undefined, preset ? { provider: preset.provider, baseUrl: preset.baseUrl } : undefined);
|
|
3935
5159
|
}
|
|
3936
5160
|
case '/style.css':
|
|
3937
|
-
return asset(
|
|
5161
|
+
return asset(DASHBOARD_ASSETS.css, 'text/css; charset=utf-8', url, ASSET_VERSIONS.css);
|
|
3938
5162
|
case '/app.js':
|
|
3939
|
-
return asset(
|
|
5163
|
+
return asset(DASHBOARD_ASSETS.js, 'text/javascript; charset=utf-8', url, ASSET_VERSIONS.js);
|
|
3940
5164
|
case '/favicon.svg':
|
|
3941
5165
|
return asset(SLOTLOCK_DASHBOARD_ICON_SVG, 'image/svg+xml', url, ASSET_VERSIONS.icon);
|
|
3942
5166
|
case '/sign-in':
|
|
@@ -3949,6 +5173,15 @@ ${rows.length === 0
|
|
|
3949
5173
|
const session = await readSession(request);
|
|
3950
5174
|
return session ? await hoursEditor(session, url) : redirect(303, root);
|
|
3951
5175
|
}
|
|
5176
|
+
case '/closures': {
|
|
5177
|
+
const session = await readSession(request);
|
|
5178
|
+
return session ? await closuresPage(session) : redirect(303, root);
|
|
5179
|
+
}
|
|
5180
|
+
case '/contacts': {
|
|
5181
|
+
const session = await readSession(request);
|
|
5182
|
+
const after = url.searchParams.get('after') ?? undefined;
|
|
5183
|
+
return session ? await contactsPage(session, 200, undefined, after) : redirect(303, root);
|
|
5184
|
+
}
|
|
3952
5185
|
case '/locations': {
|
|
3953
5186
|
const session = await readSession(request);
|
|
3954
5187
|
return session ? await locationsPage(session) : redirect(303, root);
|
|
@@ -4140,13 +5373,44 @@ a.stat:hover { background: var(--surface-2); text-decoration: none; }
|
|
|
4140
5373
|
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
5374
|
a.stat:hover::after, a.stat:focus-visible::after { opacity: 1; }
|
|
4142
5375
|
a.stat:focus-visible { outline-offset: -2px; border-radius: 0; }
|
|
4143
|
-
.stat-label { font:
|
|
5376
|
+
.stat-label { font-size: var(--text-sm); line-height: 1.4; color: var(--ink-3); }
|
|
5377
|
+
.attention-stat .stat-value { color: var(--accent); }
|
|
4144
5378
|
.stat-value { font-size: var(--text-2xl); line-height: 1.1; font-weight: 500; letter-spacing: -0.03em; font-variant-numeric: tabular-nums; }
|
|
4145
5379
|
|
|
4146
|
-
/*
|
|
4147
|
-
.
|
|
4148
|
-
.
|
|
4149
|
-
.
|
|
5380
|
+
/* Overview: what needs a person, getting started, the side column */
|
|
5381
|
+
.column { display: grid; gap: var(--space-5); align-content: start; min-width: 0; }
|
|
5382
|
+
.panel-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-3); margin-bottom: var(--space-3); }
|
|
5383
|
+
.panel-head h2 { margin: 0; }
|
|
5384
|
+
.panel-head .meta { flex: none; }
|
|
5385
|
+
.attention-list { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-2); }
|
|
5386
|
+
.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); }
|
|
5387
|
+
.attention.bad { border-color: var(--bad-line); background: var(--bad-wash); box-shadow: inset 3px 0 0 var(--bad); }
|
|
5388
|
+
.attention.warn { border-color: var(--warn-line); background: var(--warn-wash); box-shadow: inset 3px 0 0 var(--warn); }
|
|
5389
|
+
.attention.info { box-shadow: inset 3px 0 0 var(--accent); }
|
|
5390
|
+
.attention-text { flex: 1; min-width: 0; }
|
|
5391
|
+
.attention-action { flex: none; font-weight: 600; white-space: nowrap; }
|
|
5392
|
+
.segments { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: 4px; }
|
|
5393
|
+
.segments span { height: 4px; border-radius: var(--radius-full); background: var(--sunken); }
|
|
5394
|
+
.segments span.done { background: var(--good); }
|
|
5395
|
+
.setup-card .segments { margin-bottom: var(--space-3); }
|
|
5396
|
+
.setup-steps { list-style: none; margin: 0; padding: 0; display: grid; }
|
|
5397
|
+
.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); }
|
|
5398
|
+
.setup-steps li:first-child { border-top: 0; }
|
|
5399
|
+
.setup-steps li.next { color: var(--ink); font-weight: 500; }
|
|
5400
|
+
.setup-steps li.done .step-title { text-decoration: line-through; text-decoration-color: var(--line-strong); }
|
|
5401
|
+
.setup-steps .step-title { flex: 1; min-width: 0; }
|
|
5402
|
+
.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); }
|
|
5403
|
+
.setup-row:hover { background: var(--surface-2); text-decoration: none; }
|
|
5404
|
+
.setup-row .segments { width: 96px; flex: none; }
|
|
5405
|
+
.plan-card .panel-head h2 { font-size: var(--text-lg); }
|
|
5406
|
+
.meters.stacked { grid-template-columns: minmax(0, 1fr); gap: var(--space-4); margin: 0 0 var(--space-4); }
|
|
5407
|
+
.plan-card > .button { width: 100%; justify-content: center; }
|
|
5408
|
+
details.command { margin-top: var(--space-4); }
|
|
5409
|
+
details.command > summary { cursor: pointer; font-size: var(--text-md); color: var(--ink-2); }
|
|
5410
|
+
details.command[open] > summary { margin-bottom: var(--space-2); }
|
|
5411
|
+
.copy-block { position: relative; }
|
|
5412
|
+
.copy-block pre { padding-right: 72px; font-size: var(--text-sm); }
|
|
5413
|
+
.copy-block button { position: absolute; top: 6px; right: 6px; }
|
|
4150
5414
|
.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
5415
|
.done .check { background: var(--good); border: 0; }
|
|
4152
5416
|
.check .icon { width: 12px; height: 12px; stroke-width: 3; }
|
|
@@ -4164,7 +5428,11 @@ a.stat:focus-visible { outline-offset: -2px; border-radius: 0; }
|
|
|
4164
5428
|
.on { color: var(--ink-3); font-weight: 400; }
|
|
4165
5429
|
.meta { font-size: var(--text-sm); color: var(--ink-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
4166
5430
|
.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; }
|
|
5431
|
+
.event { position: relative; display: grid; grid-template-columns: 12px minmax(0, 1fr) auto; gap: var(--space-3); align-items: start; padding: 7px 0; }
|
|
5432
|
+
.event .title { font-weight: 400; color: var(--ink-2); white-space: normal; overflow-wrap: anywhere; }
|
|
5433
|
+
.event .title strong { font-weight: 600; color: var(--ink); }
|
|
5434
|
+
.event .when { font-size: var(--text-sm); font-weight: 400; color: var(--ink-3); white-space: nowrap; padding-top: 1px; }
|
|
5435
|
+
.times { font-size: var(--text-sm); color: var(--ink-3); }
|
|
4168
5436
|
.event:not(:last-child)::before { content: ""; position: absolute; left: 5.5px; top: 22px; bottom: -8px; width: 1px; background: var(--line); }
|
|
4169
5437
|
.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
5438
|
.dot.good { box-shadow: inset 0 0 0 3px var(--good); }
|
|
@@ -4204,8 +5472,6 @@ textarea { padding: 10px var(--space-3); resize: vertical; min-height: 72px; lin
|
|
|
4204
5472
|
.create .grow { flex: 1 1 220px; }
|
|
4205
5473
|
label > input, label > select, label > .unit-input { width: 100%; }
|
|
4206
5474
|
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
5475
|
label.choice > input { width: 16px; }
|
|
4210
5476
|
.create label:not(.grow) { flex: 0 1 auto; }
|
|
4211
5477
|
form > label { margin-bottom: var(--space-3); }
|
|
@@ -4295,6 +5561,22 @@ form > .table { margin-bottom: var(--space-3); }
|
|
|
4295
5561
|
/* Notices and empty states */
|
|
4296
5562
|
.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
5563
|
.notice[role="alert"] { border-color: var(--bad-line); background: var(--bad-wash); color: var(--ink); box-shadow: inset 3px 0 0 var(--bad); }
|
|
5564
|
+
.meters { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: var(--space-5) var(--space-6); margin: var(--space-4) 0; }
|
|
5565
|
+
.meter { display: grid; gap: 6px; min-width: 0; }
|
|
5566
|
+
.meter-head { display: flex; justify-content: space-between; align-items: baseline; gap: var(--space-2); font-size: var(--text-md); color: var(--ink-2); }
|
|
5567
|
+
.meter-value { color: var(--ink); font-weight: 500; font-variant-numeric: tabular-nums; }
|
|
5568
|
+
.meter progress { appearance: none; -webkit-appearance: none; width: 100%; height: 6px; border: 0; border-radius: var(--radius-full); background: var(--sunken); overflow: hidden; }
|
|
5569
|
+
.meter progress::-webkit-progress-bar { background: var(--sunken); border-radius: var(--radius-full); }
|
|
5570
|
+
.meter progress::-webkit-progress-value { background: var(--ink-2); border-radius: var(--radius-full); }
|
|
5571
|
+
.meter progress::-moz-progress-bar { background: var(--ink-2); border-radius: var(--radius-full); }
|
|
5572
|
+
.meter.warn progress::-webkit-progress-value { background: var(--warn); }
|
|
5573
|
+
.meter.warn progress::-moz-progress-bar { background: var(--warn); }
|
|
5574
|
+
.meter.bad progress::-webkit-progress-value { background: var(--bad); }
|
|
5575
|
+
.meter.bad progress::-moz-progress-bar { background: var(--bad); }
|
|
5576
|
+
.meter.bad .meter-value { color: var(--bad); }
|
|
5577
|
+
.plan-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-3); }
|
|
5578
|
+
.plan-head h2 { margin: 0; }
|
|
5579
|
+
.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
5580
|
.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
5581
|
.notice p { color: inherit; margin: 0 0 var(--space-2); }
|
|
4300
5582
|
.empty { color: var(--ink-3); margin: 0; }
|
|
@@ -4331,23 +5613,6 @@ pre { background: var(--surface-2); border: 1px solid var(--line); border-radius
|
|
|
4331
5613
|
.bare-main > .notice { max-width: 480px; }
|
|
4332
5614
|
|
|
4333
5615
|
/* 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
5616
|
|
|
4352
5617
|
@media (max-width: 1080px) {
|
|
4353
5618
|
.split { grid-template-columns: minmax(0, 1fr); }
|
|
@@ -4377,6 +5642,8 @@ pre { background: var(--surface-2); border: 1px solid var(--line); border-radius
|
|
|
4377
5642
|
.stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
4378
5643
|
.stat { padding: 14px var(--space-4); box-shadow: -1px 0 0 var(--line), 0 -1px 0 var(--line); }
|
|
4379
5644
|
.stat-value { font-size: 22px; }
|
|
5645
|
+
.attention { flex-wrap: wrap; gap: var(--space-1) var(--space-4); }
|
|
5646
|
+
.setup-row .segments { width: 56px; }
|
|
4380
5647
|
.create > * { flex: 1 1 100%; }
|
|
4381
5648
|
.create button { width: 100%; }
|
|
4382
5649
|
.endpoints { grid-template-columns: minmax(0, 1fr); gap: var(--space-1); }
|
|
@@ -4405,8 +5672,6 @@ pre { background: var(--surface-2); border: 1px solid var(--line); border-radius
|
|
|
4405
5672
|
.act.danger summary { border-color: var(--bad-line); }
|
|
4406
5673
|
.pop { left: 0; right: auto; width: min(300px, calc(100vw - 48px)); transform-origin: top left; }
|
|
4407
5674
|
.table .pop { margin-left: 0; }
|
|
4408
|
-
.msg { max-width: 92%; }
|
|
4409
|
-
.composer { grid-template-columns: minmax(0, 1fr); }
|
|
4410
5675
|
.empty-state { padding: var(--space-8) var(--space-4); }
|
|
4411
5676
|
.bare-main { padding-top: 6vh; }
|
|
4412
5677
|
.sign-in { padding: var(--space-6); }
|
|
@@ -4425,10 +5690,15 @@ const COMPOSITE_PROBLEMS = Object.freeze({
|
|
|
4425
5690
|
composite_parts_limit: 'A whole has at most 50 parts.',
|
|
4426
5691
|
composite_busy: 'One of them has a booking or hold still to come. A composite changes only while neither does.',
|
|
4427
5692
|
});
|
|
4428
|
-
/** What the
|
|
5693
|
+
/** What the Usage page calls each plan. */
|
|
4429
5694
|
const PLAN_NAMES = Object.freeze({ free: 'Free', developer: 'Developer', team: 'Team' });
|
|
5695
|
+
const DASHBOARD_ASSETS = Object.freeze({
|
|
5696
|
+
css: DASHBOARD_CSS + SLOTLOCK_ASSISTANT_CSS + SLOTLOCK_SETTINGS_CSS,
|
|
5697
|
+
// The chat's script renders a reply with the server's own Markdown renderer, embedded.
|
|
5698
|
+
js: DASHBOARD_JS + slotlockAssistantScript(renderSlotlockMarkdown.toString()),
|
|
5699
|
+
});
|
|
4430
5700
|
const ASSET_VERSIONS = Object.freeze({
|
|
4431
|
-
css: createHash('sha256').update(
|
|
4432
|
-
js: createHash('sha256').update(
|
|
5701
|
+
css: createHash('sha256').update(DASHBOARD_ASSETS.css).digest('base64url').slice(0, 12),
|
|
5702
|
+
js: createHash('sha256').update(DASHBOARD_ASSETS.js).digest('base64url').slice(0, 12),
|
|
4433
5703
|
icon: createHash('sha256').update(SLOTLOCK_DASHBOARD_ICON_SVG).digest('base64url').slice(0, 12),
|
|
4434
5704
|
});
|