Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/next/components/store/quota-config.tsx
6034 views
1
/*
2
* This file is part of CoCalc: Copyright © 2022 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
import {
7
Alert,
8
Button,
9
Col,
10
Divider,
11
Flex,
12
Form,
13
Radio,
14
Row,
15
Space,
16
Tabs,
17
Typography,
18
} from "antd";
19
import { useEffect, useRef, useState, type JSX } from "react";
20
21
import { HelpIcon } from "@cocalc/frontend/components/help-icon";
22
import { Icon } from "@cocalc/frontend/components/icon";
23
import { displaySiteLicense } from "@cocalc/util/consts/site-license";
24
import { plural, unreachable } from "@cocalc/util/misc";
25
import {
26
BOOST,
27
DISK_DEFAULT_GB,
28
MAX_DISK_GB,
29
MIN_DISK_GB,
30
REGULAR,
31
} from "@cocalc/util/upgrades/consts";
32
import type { LicenseSource } from "@cocalc/util/upgrades/shopping";
33
34
import PricingItem, { Line } from "components/landing/pricing-item";
35
import { CSS, Paragraph } from "components/misc";
36
import A from "components/misc/A";
37
import IntegerSlider from "components/misc/integer-slider";
38
import {
39
COURSE,
40
SITE_LICENSE,
41
PRESET_MATCH_FIELDS,
42
Preset,
43
PresetConfig,
44
} from "./quota-config-presets";
45
46
const { Text } = Typography;
47
48
const EXPERT_CONFIG = "Expert Configuration";
49
const listFormat = new Intl.ListFormat("en");
50
51
const RAM_HIGH_WARN_THRESHOLD = 10;
52
const RAM_LOW_WARN_THRESHOLD = 1;
53
const MEM_MIN_RECOMMEND = 2;
54
const CPU_HIGH_WARN_THRESHOLD = 3;
55
56
const WARNING_BOX: CSS = { marginTop: "10px", marginBottom: "10px" };
57
58
interface Props {
59
showExplanations: boolean;
60
form: any;
61
disabled?: boolean;
62
onChange: () => void;
63
boost?: boolean;
64
// boost doesn't define any of the below, that's only for site-license
65
configMode?: "preset" | "expert";
66
setConfigMode?: (mode: "preset" | "expert") => void;
67
preset?: Preset | null;
68
setPreset?: (preset: Preset | null) => void;
69
presetAdjusted?: boolean;
70
setPresetAdjusted?: (adjusted: boolean) => void;
71
source: LicenseSource;
72
}
73
74
export const QuotaConfig: React.FC<Props> = (props: Props) => {
75
const {
76
showExplanations,
77
form,
78
disabled = false,
79
onChange,
80
boost = false,
81
configMode,
82
setConfigMode,
83
preset,
84
setPreset,
85
presetAdjusted,
86
setPresetAdjusted,
87
source,
88
} = props;
89
90
const presetsRef = useRef<HTMLDivElement>(null);
91
const [isClient, setIsClient] = useState(false);
92
const [narrow, setNarrow] = useState<boolean>(false);
93
94
useEffect(() => {
95
setIsClient(true);
96
}, []);
97
98
useEffect(() => {
99
const observer = new ResizeObserver((entries) => {
100
if (isClient && entries[0].contentRect.width < 600) {
101
setNarrow(true);
102
} else {
103
setNarrow(false);
104
}
105
});
106
107
if (presetsRef.current) {
108
observer.observe(presetsRef.current);
109
}
110
111
return () => {
112
observer.disconnect();
113
};
114
}, [presetsRef.current]);
115
116
const ramVal = Form.useWatch("ram", form);
117
const cpuVal = Form.useWatch("cpu", form);
118
119
function title() {
120
if (boost) {
121
return "Booster";
122
} else {
123
switch (source) {
124
case "site-license":
125
return "Quota Upgrades";
126
case "course":
127
return "Project Upgrades";
128
default:
129
unreachable(source);
130
}
131
}
132
}
133
134
const PARAMS = boost ? BOOST : REGULAR;
135
136
function explainRam() {
137
return (
138
<>
139
{renderRamInfo()}
140
{showExplanations ? (
141
<>
142
This quota limits the total amount of memory a project can use. Note
143
that RAM may be limited, if many other users are using the same host
144
– though member hosting significantly reduces competition for RAM.
145
We recommend at least {MEM_MIN_RECOMMEND}G!
146
</>
147
) : undefined}
148
</>
149
);
150
}
151
152
/**
153
* When a quota is changed, we warn the user that the preset was adjusted.
154
* (the text updates, though, since it rerenders every time). Explanation in
155
* the details could make no sense, though – that's why this is added.
156
*/
157
function presetWasAdjusted() {
158
setPresetAdjusted?.(true);
159
}
160
161
function renderRamInfo() {
162
if (ramVal >= RAM_HIGH_WARN_THRESHOLD) {
163
return (
164
<Alert
165
style={WARNING_BOX}
166
type="warning"
167
message="Consider using a compute server?"
168
description={
169
<>
170
You selected a RAM quota of {ramVal}G. If your use-case involves a
171
lot of RAM, consider using a{" "}
172
<A href="https://doc.cocalc.com/compute_server.html">
173
compute server.
174
</A>
175
</>
176
}
177
/>
178
);
179
} else if (!boost && ramVal <= RAM_LOW_WARN_THRESHOLD) {
180
return (
181
<Alert
182
style={WARNING_BOX}
183
type="warning"
184
message="Low memory"
185
description={
186
<>
187
Your choice of {ramVal}G of RAM is beyond our recommendation of at
188
least {MEM_MIN_RECOMMEND}G. You will not be able to run several
189
notebooks at once, use SageMath or Julia effectively, etc.
190
</>
191
}
192
/>
193
);
194
}
195
}
196
197
function ram() {
198
return (
199
<Form.Item
200
label="Shared RAM"
201
name="ram"
202
initialValue={PARAMS.ram.dflt}
203
extra={explainRam()}
204
>
205
<IntegerSlider
206
disabled={disabled}
207
min={PARAMS.ram.min}
208
max={PARAMS.ram.max}
209
onChange={(ram) => {
210
form.setFieldsValue({ ram });
211
presetWasAdjusted();
212
onChange();
213
}}
214
units={"GB RAM"}
215
presets={boost ? [0, 2, 4, 8, 10] : [4, 8, 16]}
216
/>
217
</Form.Item>
218
);
219
}
220
221
function renderCpuInfo() {
222
if (cpuVal >= CPU_HIGH_WARN_THRESHOLD) {
223
return (
224
<Alert
225
style={WARNING_BOX}
226
type="warning"
227
message="Consider using a compute server?"
228
description={
229
<>
230
You selected a CPU quota of {cpuVal} vCPU cores is high. If your
231
use-case involves harnessing a lot of CPU power, consider using a{" "}
232
<A href="https://doc.cocalc.com/compute_server.html">
233
compute server
234
</A>{" "}
235
or{" "}
236
<A href={"/store/dedicated?type=vm"}>
237
dedicated virtual machines
238
</A>
239
. This will not only give you many more CPU cores, but also a far
240
superior experience!
241
</>
242
}
243
/>
244
);
245
}
246
}
247
248
function renderCpuExtra() {
249
return (
250
<>
251
{renderCpuInfo()}
252
{showExplanations ? (
253
<>
254
<A href="https://cloud.google.com/compute/docs/faq#virtualcpu">
255
Google Cloud vCPUs.
256
</A>{" "}
257
To keep prices low, these vCPUs may be shared with other projects,
258
though member hosting very significantly reduces competition for
259
CPUs.
260
</>
261
) : undefined}
262
</>
263
);
264
}
265
266
function cpu() {
267
return (
268
<Form.Item
269
label="Shared CPUs"
270
name="cpu"
271
initialValue={PARAMS.cpu.dflt}
272
extra={renderCpuExtra()}
273
>
274
<IntegerSlider
275
disabled={disabled}
276
min={PARAMS.cpu.min}
277
max={PARAMS.cpu.max}
278
onChange={(cpu) => {
279
form.setFieldsValue({ cpu });
280
presetWasAdjusted();
281
onChange();
282
}}
283
units={"vCPU"}
284
presets={boost ? [0, 1, 2] : [1, 2, 3]}
285
/>
286
</Form.Item>
287
);
288
}
289
290
function generateDiskPresets(min: number, max: number): number[] {
291
if (min >= max) return [min];
292
293
const range = max - min;
294
const presets = [min]; // Always include minimum
295
296
// Create 3-4 evenly spaced values
297
const step = Math.ceil(range / 4);
298
let current = min + step;
299
while (current < max) {
300
presets.push(current);
301
current += step;
302
}
303
304
presets.push(max); // Always include maximum
305
return [...new Set(presets)].sort((a, b) => a - b); // Remove duplicates and sort
306
}
307
308
function disk() {
309
// Generate dynamic presets based on MIN_DISK_GB and MAX_DISK_GB
310
const presets = boost
311
? [0, ...generateDiskPresets(MIN_DISK_GB, PARAMS.disk.max).slice(0, 3)] // For boost, include 0 and limit to 3 additional values
312
: generateDiskPresets(MIN_DISK_GB, MAX_DISK_GB);
313
return (
314
<Form.Item
315
label="Disk space"
316
name="disk"
317
initialValue={PARAMS.disk.dflt}
318
extra={
319
showExplanations ? (
320
<>
321
Extra disk space lets you store a larger number of files.
322
Snapshots and file edit history is included at no additional
323
charge. Each project receives at least {DISK_DEFAULT_GB}G of
324
storage space. We also offer MUCH larger disks (and CPU and
325
memory) via{" "}
326
<A href="https://doc.cocalc.com/compute_server.html">
327
compute server
328
</A>
329
.
330
</>
331
) : undefined
332
}
333
>
334
<IntegerSlider
335
disabled={disabled}
336
min={PARAMS.disk.min}
337
max={PARAMS.disk.max}
338
onChange={(disk) => {
339
form.setFieldsValue({ disk });
340
presetWasAdjusted();
341
onChange();
342
}}
343
units={"G Disk"}
344
presets={presets}
345
/>
346
</Form.Item>
347
);
348
}
349
350
function presetIsAdjusted() {
351
if (preset == null) return;
352
const presetData: PresetConfig = SITE_LICENSE[preset];
353
if (presetData == null) {
354
return (
355
<div>
356
Error: preset <code>{preset}</code> is not known.
357
</div>
358
);
359
}
360
361
const quotaConfig: Record<string, string> = form.getFieldsValue(
362
Object.keys(PRESET_MATCH_FIELDS),
363
);
364
const invalidConfigValues = Object.keys(quotaConfig).filter(
365
(field) => quotaConfig[field] == null,
366
);
367
if (invalidConfigValues.length) {
368
return;
369
}
370
371
const presetDiff = Object.keys(PRESET_MATCH_FIELDS).reduce(
372
(diff, presetField) => {
373
if (presetData[presetField] !== quotaConfig[presetField]) {
374
diff.push(PRESET_MATCH_FIELDS[presetField]);
375
}
376
377
return diff;
378
},
379
[] as string[],
380
);
381
382
if (!presetAdjusted || !presetDiff.length) return;
383
return (
384
<Alert
385
type="warning"
386
style={{ marginBottom: "20px" }}
387
message={
388
<>
389
The currently configured license differs from the selected preset in{" "}
390
<strong>{listFormat.format(presetDiff)}</strong>. By clicking any of
391
the presets below, you reconfigure your license configuration to
392
match the original preset.
393
</>
394
}
395
/>
396
);
397
}
398
399
function renderIdleTimeoutWithHelp(text?: string) {
400
return (
401
<HelpIcon title="Idle Timeout" extra={text || "idle timeout"}>
402
The idle timeout determines how long your project stays running after
403
you stop using it. For example, if you work in your project for 2 hours,
404
it will keep running during that time. When you close your browser or
405
stop working, the project will automatically shut down after the idle
406
timeout period. Don't worry - your files are always saved and you can
407
restart the project anytime to continue your work exactly where you left
408
off.
409
</HelpIcon>
410
);
411
}
412
413
function presetsCommon() {
414
if (!showExplanations) return null;
415
return (
416
<Text type="secondary">
417
{preset == null ? (
418
<>After selecting a preset, feel free to</>
419
) : (
420
<>
421
Selected preset <strong>"{SITE_LICENSE[preset]?.name}"</strong>. You
422
can
423
</>
424
)}{" "}
425
fine tune the selection in the "{EXPERT_CONFIG}" tab. Subsequent preset
426
selections will reset your adjustments.
427
</Text>
428
);
429
}
430
431
function renderNoPresetWarning() {
432
if (preset != null) return;
433
return (
434
<Text type="danger">
435
Currently, no preset selection is active. Select a preset above to reset
436
your recent changes.
437
</Text>
438
);
439
}
440
441
function renderCoursePresets() {
442
const p = preset != null ? COURSE[preset] : undefined;
443
let presetInfo: JSX.Element | undefined = undefined;
444
if (p != null) {
445
const { name, cpu, disk, ram, uptime, note, details } = p;
446
const basic = (
447
<>
448
Each student project will be outfitted with up to{" "}
449
<Text strong>
450
{cpu} {plural(cpu, "vCPU")}
451
</Text>
452
, <Text strong>{ram} GB memory</Text>, and{" "}
453
<Text strong>{disk} GB disk space</Text> with an{" "}
454
<Text strong>
455
{renderIdleTimeoutWithHelp()} of {displaySiteLicense(uptime)}
456
</Text>
457
.
458
</>
459
);
460
presetInfo = (
461
<>
462
<Paragraph>
463
<strong>{name}:</strong> {note} {basic}
464
</Paragraph>
465
<Paragraph type="secondary">{details}</Paragraph>
466
</>
467
);
468
}
469
470
return (
471
<>
472
<Form.Item label="Presets">
473
<Radio.Group
474
size="large"
475
value={preset}
476
onChange={(e) => onPresetChange(COURSE, e.target.value)}
477
>
478
<Space direction="vertical">
479
{(Object.keys(COURSE) as Array<Preset>).map((p) => {
480
const { name, icon, descr } = COURSE[p];
481
return (
482
<Radio key={p} value={p}>
483
<span>
484
<Icon name={icon ?? "arrow-up"} />{" "}
485
<strong>{name}:</strong> {descr}
486
</span>
487
</Radio>
488
);
489
})}
490
</Space>
491
</Radio.Group>
492
</Form.Item>
493
<Form.Item label={null}>{presetInfo}</Form.Item>
494
</>
495
);
496
}
497
498
function renderPresetsNarrow() {
499
const p = preset != null ? SITE_LICENSE[preset] : undefined;
500
let presetInfo: JSX.Element | undefined = undefined;
501
if (p != null) {
502
const { name, cpu, disk, ram, uptime, note } = p;
503
const basic = (
504
<>
505
provides up to{" "}
506
<Text strong>
507
{cpu} {plural(cpu, "vCPU")}
508
</Text>
509
, <Text strong>{ram} GB memory</Text>, and{" "}
510
<Text strong>{disk} GB disk space</Text> for each project.
511
</>
512
);
513
const ut = (
514
<>
515
the project's{" "}
516
<Text strong>
517
{renderIdleTimeoutWithHelp()} is {displaySiteLicense(uptime)}
518
</Text>
519
</>
520
);
521
presetInfo = (
522
<Paragraph>
523
<strong>{name}</strong> {basic} Additionally, {ut}. {note}
524
</Paragraph>
525
);
526
}
527
528
return (
529
<>
530
<Form.Item label="Preset">
531
<Radio.Group
532
size="large"
533
value={preset}
534
onChange={(e) => onPresetChange(SITE_LICENSE, e.target.value)}
535
>
536
<Space direction="vertical">
537
{(Object.keys(SITE_LICENSE) as Array<Preset>).map((p) => {
538
const { name, icon, descr } = SITE_LICENSE[p];
539
return (
540
<Radio key={p} value={p}>
541
<span>
542
<Icon name={icon ?? "arrow-up"} />{" "}
543
<strong>{name}:</strong> {descr}
544
</span>
545
</Radio>
546
);
547
})}
548
</Space>
549
</Radio.Group>
550
</Form.Item>
551
{presetInfo}
552
</>
553
);
554
}
555
556
function renderPresetPanels() {
557
if (narrow) return renderPresetsNarrow();
558
559
const panels = (Object.keys(SITE_LICENSE) as Array<Preset>).map(
560
(p, idx) => {
561
const { name, icon, cpu, ram, disk, uptime, expect, descr, note } =
562
SITE_LICENSE[p];
563
const active = preset === p;
564
return (
565
<PricingItem
566
key={idx}
567
title={name}
568
icon={icon}
569
style={{ flex: 1 }}
570
active={active}
571
onClick={() => onPresetChange(SITE_LICENSE, p)}
572
>
573
<Paragraph>
574
<strong>{name}</strong> {descr}.
575
</Paragraph>
576
<Divider />
577
<Line amount={cpu} desc={"CPU"} indent={false} />
578
<Line amount={ram} desc={"RAM"} indent={false} />
579
<Line amount={disk} desc={"Disk space"} indent={false} />
580
<Line
581
amount={displaySiteLicense(uptime)}
582
desc={renderIdleTimeoutWithHelp("Idle timeout")}
583
indent={false}
584
/>
585
<Divider />
586
<Paragraph>
587
<Text type="secondary">
588
In each project, you will be able to:
589
</Text>
590
<ul>
591
{expect.map((what, idx) => (
592
<li key={idx}>{what}</li>
593
))}
594
</ul>
595
</Paragraph>
596
{active && note != null ? (
597
<>
598
<Divider />
599
<Paragraph type="secondary">{note}</Paragraph>
600
</>
601
) : undefined}
602
<Paragraph style={{ marginTop: "20px", textAlign: "center" }}>
603
<Button
604
onClick={() => onPresetChange(SITE_LICENSE, p)}
605
size="large"
606
type={active ? "primary" : undefined}
607
>
608
{name}
609
</Button>
610
</Paragraph>
611
</PricingItem>
612
);
613
},
614
);
615
return (
616
<Flex
617
style={{ width: "100%" }}
618
justify={"space-between"}
619
align={"flex-start"}
620
gap="10px"
621
>
622
{panels}
623
</Flex>
624
);
625
}
626
627
function presetExtra() {
628
return (
629
<Space ref={presetsRef} direction="vertical">
630
<div>
631
{presetIsAdjusted()}
632
{renderPresetPanels()}
633
{renderNoPresetWarning()}
634
</div>
635
{presetsCommon()}
636
</Space>
637
);
638
}
639
640
function onPresetChange(
641
preset: { [key: string]: PresetConfig },
642
val: Preset,
643
) {
644
if (val == null || setPreset == null) return;
645
setPreset(val);
646
setPresetAdjusted?.(false);
647
const presetData = preset[val];
648
if (presetData != null) {
649
const { cpu, ram, disk, uptime = "short", member = true } = presetData;
650
form.setFieldsValue({ uptime, member, cpu, ram, disk });
651
}
652
onChange();
653
}
654
655
function detailed() {
656
return (
657
<>
658
{ram()}
659
{cpu()}
660
{disk()}
661
</>
662
);
663
}
664
665
function main() {
666
if (boost) {
667
return (
668
<>
669
<Row>
670
<Col xs={16} offset={6} style={{ marginBottom: "20px" }}>
671
<Text type="secondary">
672
Configure the quotas you want to add on top of your existing
673
license. E.g. if your license provides a limit of 2 GB of RAM
674
and you add a matching boost license with 3 GB of RAM, you'll
675
end up with a total quota limit of 5 GB of RAM.
676
</Text>
677
</Col>
678
</Row>
679
{detailed()}
680
</>
681
);
682
} else {
683
switch (source) {
684
case "site-license":
685
return (
686
<Tabs
687
activeKey={configMode}
688
onChange={setConfigMode}
689
type="card"
690
tabPosition="top"
691
size="middle"
692
centered={true}
693
items={[
694
{
695
key: "preset",
696
label: (
697
<span>
698
<Icon name="gears" style={{ marginRight: "5px" }} />
699
Presets
700
</span>
701
),
702
children: presetExtra(),
703
},
704
{
705
key: "expert",
706
label: (
707
<span>
708
<Icon name="wrench" style={{ marginRight: "5px" }} />
709
{EXPERT_CONFIG}
710
</span>
711
),
712
children: detailed(),
713
},
714
]}
715
/>
716
);
717
case "course":
718
return renderCoursePresets();
719
default:
720
unreachable(source);
721
}
722
}
723
}
724
725
return (
726
<>
727
<Divider plain>{title()}</Divider>
728
{main()}
729
</>
730
);
731
};
732
733