Path: blob/master/src/packages/next/components/store/quota-config.tsx
6034 views
/*1* This file is part of CoCalc: Copyright © 2022 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45import {6Alert,7Button,8Col,9Divider,10Flex,11Form,12Radio,13Row,14Space,15Tabs,16Typography,17} from "antd";18import { useEffect, useRef, useState, type JSX } from "react";1920import { HelpIcon } from "@cocalc/frontend/components/help-icon";21import { Icon } from "@cocalc/frontend/components/icon";22import { displaySiteLicense } from "@cocalc/util/consts/site-license";23import { plural, unreachable } from "@cocalc/util/misc";24import {25BOOST,26DISK_DEFAULT_GB,27MAX_DISK_GB,28MIN_DISK_GB,29REGULAR,30} from "@cocalc/util/upgrades/consts";31import type { LicenseSource } from "@cocalc/util/upgrades/shopping";3233import PricingItem, { Line } from "components/landing/pricing-item";34import { CSS, Paragraph } from "components/misc";35import A from "components/misc/A";36import IntegerSlider from "components/misc/integer-slider";37import {38COURSE,39SITE_LICENSE,40PRESET_MATCH_FIELDS,41Preset,42PresetConfig,43} from "./quota-config-presets";4445const { Text } = Typography;4647const EXPERT_CONFIG = "Expert Configuration";48const listFormat = new Intl.ListFormat("en");4950const RAM_HIGH_WARN_THRESHOLD = 10;51const RAM_LOW_WARN_THRESHOLD = 1;52const MEM_MIN_RECOMMEND = 2;53const CPU_HIGH_WARN_THRESHOLD = 3;5455const WARNING_BOX: CSS = { marginTop: "10px", marginBottom: "10px" };5657interface Props {58showExplanations: boolean;59form: any;60disabled?: boolean;61onChange: () => void;62boost?: boolean;63// boost doesn't define any of the below, that's only for site-license64configMode?: "preset" | "expert";65setConfigMode?: (mode: "preset" | "expert") => void;66preset?: Preset | null;67setPreset?: (preset: Preset | null) => void;68presetAdjusted?: boolean;69setPresetAdjusted?: (adjusted: boolean) => void;70source: LicenseSource;71}7273export const QuotaConfig: React.FC<Props> = (props: Props) => {74const {75showExplanations,76form,77disabled = false,78onChange,79boost = false,80configMode,81setConfigMode,82preset,83setPreset,84presetAdjusted,85setPresetAdjusted,86source,87} = props;8889const presetsRef = useRef<HTMLDivElement>(null);90const [isClient, setIsClient] = useState(false);91const [narrow, setNarrow] = useState<boolean>(false);9293useEffect(() => {94setIsClient(true);95}, []);9697useEffect(() => {98const observer = new ResizeObserver((entries) => {99if (isClient && entries[0].contentRect.width < 600) {100setNarrow(true);101} else {102setNarrow(false);103}104});105106if (presetsRef.current) {107observer.observe(presetsRef.current);108}109110return () => {111observer.disconnect();112};113}, [presetsRef.current]);114115const ramVal = Form.useWatch("ram", form);116const cpuVal = Form.useWatch("cpu", form);117118function title() {119if (boost) {120return "Booster";121} else {122switch (source) {123case "site-license":124return "Quota Upgrades";125case "course":126return "Project Upgrades";127default:128unreachable(source);129}130}131}132133const PARAMS = boost ? BOOST : REGULAR;134135function explainRam() {136return (137<>138{renderRamInfo()}139{showExplanations ? (140<>141This quota limits the total amount of memory a project can use. Note142that RAM may be limited, if many other users are using the same host143– though member hosting significantly reduces competition for RAM.144We recommend at least {MEM_MIN_RECOMMEND}G!145</>146) : undefined}147</>148);149}150151/**152* When a quota is changed, we warn the user that the preset was adjusted.153* (the text updates, though, since it rerenders every time). Explanation in154* the details could make no sense, though – that's why this is added.155*/156function presetWasAdjusted() {157setPresetAdjusted?.(true);158}159160function renderRamInfo() {161if (ramVal >= RAM_HIGH_WARN_THRESHOLD) {162return (163<Alert164style={WARNING_BOX}165type="warning"166message="Consider using a compute server?"167description={168<>169You selected a RAM quota of {ramVal}G. If your use-case involves a170lot of RAM, consider using a{" "}171<A href="https://doc.cocalc.com/compute_server.html">172compute server.173</A>174</>175}176/>177);178} else if (!boost && ramVal <= RAM_LOW_WARN_THRESHOLD) {179return (180<Alert181style={WARNING_BOX}182type="warning"183message="Low memory"184description={185<>186Your choice of {ramVal}G of RAM is beyond our recommendation of at187least {MEM_MIN_RECOMMEND}G. You will not be able to run several188notebooks at once, use SageMath or Julia effectively, etc.189</>190}191/>192);193}194}195196function ram() {197return (198<Form.Item199label="Shared RAM"200name="ram"201initialValue={PARAMS.ram.dflt}202extra={explainRam()}203>204<IntegerSlider205disabled={disabled}206min={PARAMS.ram.min}207max={PARAMS.ram.max}208onChange={(ram) => {209form.setFieldsValue({ ram });210presetWasAdjusted();211onChange();212}}213units={"GB RAM"}214presets={boost ? [0, 2, 4, 8, 10] : [4, 8, 16]}215/>216</Form.Item>217);218}219220function renderCpuInfo() {221if (cpuVal >= CPU_HIGH_WARN_THRESHOLD) {222return (223<Alert224style={WARNING_BOX}225type="warning"226message="Consider using a compute server?"227description={228<>229You selected a CPU quota of {cpuVal} vCPU cores is high. If your230use-case involves harnessing a lot of CPU power, consider using a{" "}231<A href="https://doc.cocalc.com/compute_server.html">232compute server233</A>{" "}234or{" "}235<A href={"/store/dedicated?type=vm"}>236dedicated virtual machines237</A>238. This will not only give you many more CPU cores, but also a far239superior experience!240</>241}242/>243);244}245}246247function renderCpuExtra() {248return (249<>250{renderCpuInfo()}251{showExplanations ? (252<>253<A href="https://cloud.google.com/compute/docs/faq#virtualcpu">254Google Cloud vCPUs.255</A>{" "}256To keep prices low, these vCPUs may be shared with other projects,257though member hosting very significantly reduces competition for258CPUs.259</>260) : undefined}261</>262);263}264265function cpu() {266return (267<Form.Item268label="Shared CPUs"269name="cpu"270initialValue={PARAMS.cpu.dflt}271extra={renderCpuExtra()}272>273<IntegerSlider274disabled={disabled}275min={PARAMS.cpu.min}276max={PARAMS.cpu.max}277onChange={(cpu) => {278form.setFieldsValue({ cpu });279presetWasAdjusted();280onChange();281}}282units={"vCPU"}283presets={boost ? [0, 1, 2] : [1, 2, 3]}284/>285</Form.Item>286);287}288289function generateDiskPresets(min: number, max: number): number[] {290if (min >= max) return [min];291292const range = max - min;293const presets = [min]; // Always include minimum294295// Create 3-4 evenly spaced values296const step = Math.ceil(range / 4);297let current = min + step;298while (current < max) {299presets.push(current);300current += step;301}302303presets.push(max); // Always include maximum304return [...new Set(presets)].sort((a, b) => a - b); // Remove duplicates and sort305}306307function disk() {308// Generate dynamic presets based on MIN_DISK_GB and MAX_DISK_GB309const presets = boost310? [0, ...generateDiskPresets(MIN_DISK_GB, PARAMS.disk.max).slice(0, 3)] // For boost, include 0 and limit to 3 additional values311: generateDiskPresets(MIN_DISK_GB, MAX_DISK_GB);312return (313<Form.Item314label="Disk space"315name="disk"316initialValue={PARAMS.disk.dflt}317extra={318showExplanations ? (319<>320Extra disk space lets you store a larger number of files.321Snapshots and file edit history is included at no additional322charge. Each project receives at least {DISK_DEFAULT_GB}G of323storage space. We also offer MUCH larger disks (and CPU and324memory) via{" "}325<A href="https://doc.cocalc.com/compute_server.html">326compute server327</A>328.329</>330) : undefined331}332>333<IntegerSlider334disabled={disabled}335min={PARAMS.disk.min}336max={PARAMS.disk.max}337onChange={(disk) => {338form.setFieldsValue({ disk });339presetWasAdjusted();340onChange();341}}342units={"G Disk"}343presets={presets}344/>345</Form.Item>346);347}348349function presetIsAdjusted() {350if (preset == null) return;351const presetData: PresetConfig = SITE_LICENSE[preset];352if (presetData == null) {353return (354<div>355Error: preset <code>{preset}</code> is not known.356</div>357);358}359360const quotaConfig: Record<string, string> = form.getFieldsValue(361Object.keys(PRESET_MATCH_FIELDS),362);363const invalidConfigValues = Object.keys(quotaConfig).filter(364(field) => quotaConfig[field] == null,365);366if (invalidConfigValues.length) {367return;368}369370const presetDiff = Object.keys(PRESET_MATCH_FIELDS).reduce(371(diff, presetField) => {372if (presetData[presetField] !== quotaConfig[presetField]) {373diff.push(PRESET_MATCH_FIELDS[presetField]);374}375376return diff;377},378[] as string[],379);380381if (!presetAdjusted || !presetDiff.length) return;382return (383<Alert384type="warning"385style={{ marginBottom: "20px" }}386message={387<>388The currently configured license differs from the selected preset in{" "}389<strong>{listFormat.format(presetDiff)}</strong>. By clicking any of390the presets below, you reconfigure your license configuration to391match the original preset.392</>393}394/>395);396}397398function renderIdleTimeoutWithHelp(text?: string) {399return (400<HelpIcon title="Idle Timeout" extra={text || "idle timeout"}>401The idle timeout determines how long your project stays running after402you stop using it. For example, if you work in your project for 2 hours,403it will keep running during that time. When you close your browser or404stop working, the project will automatically shut down after the idle405timeout period. Don't worry - your files are always saved and you can406restart the project anytime to continue your work exactly where you left407off.408</HelpIcon>409);410}411412function presetsCommon() {413if (!showExplanations) return null;414return (415<Text type="secondary">416{preset == null ? (417<>After selecting a preset, feel free to</>418) : (419<>420Selected preset <strong>"{SITE_LICENSE[preset]?.name}"</strong>. You421can422</>423)}{" "}424fine tune the selection in the "{EXPERT_CONFIG}" tab. Subsequent preset425selections will reset your adjustments.426</Text>427);428}429430function renderNoPresetWarning() {431if (preset != null) return;432return (433<Text type="danger">434Currently, no preset selection is active. Select a preset above to reset435your recent changes.436</Text>437);438}439440function renderCoursePresets() {441const p = preset != null ? COURSE[preset] : undefined;442let presetInfo: JSX.Element | undefined = undefined;443if (p != null) {444const { name, cpu, disk, ram, uptime, note, details } = p;445const basic = (446<>447Each student project will be outfitted with up to{" "}448<Text strong>449{cpu} {plural(cpu, "vCPU")}450</Text>451, <Text strong>{ram} GB memory</Text>, and{" "}452<Text strong>{disk} GB disk space</Text> with an{" "}453<Text strong>454{renderIdleTimeoutWithHelp()} of {displaySiteLicense(uptime)}455</Text>456.457</>458);459presetInfo = (460<>461<Paragraph>462<strong>{name}:</strong> {note} {basic}463</Paragraph>464<Paragraph type="secondary">{details}</Paragraph>465</>466);467}468469return (470<>471<Form.Item label="Presets">472<Radio.Group473size="large"474value={preset}475onChange={(e) => onPresetChange(COURSE, e.target.value)}476>477<Space direction="vertical">478{(Object.keys(COURSE) as Array<Preset>).map((p) => {479const { name, icon, descr } = COURSE[p];480return (481<Radio key={p} value={p}>482<span>483<Icon name={icon ?? "arrow-up"} />{" "}484<strong>{name}:</strong> {descr}485</span>486</Radio>487);488})}489</Space>490</Radio.Group>491</Form.Item>492<Form.Item label={null}>{presetInfo}</Form.Item>493</>494);495}496497function renderPresetsNarrow() {498const p = preset != null ? SITE_LICENSE[preset] : undefined;499let presetInfo: JSX.Element | undefined = undefined;500if (p != null) {501const { name, cpu, disk, ram, uptime, note } = p;502const basic = (503<>504provides up to{" "}505<Text strong>506{cpu} {plural(cpu, "vCPU")}507</Text>508, <Text strong>{ram} GB memory</Text>, and{" "}509<Text strong>{disk} GB disk space</Text> for each project.510</>511);512const ut = (513<>514the project's{" "}515<Text strong>516{renderIdleTimeoutWithHelp()} is {displaySiteLicense(uptime)}517</Text>518</>519);520presetInfo = (521<Paragraph>522<strong>{name}</strong> {basic} Additionally, {ut}. {note}523</Paragraph>524);525}526527return (528<>529<Form.Item label="Preset">530<Radio.Group531size="large"532value={preset}533onChange={(e) => onPresetChange(SITE_LICENSE, e.target.value)}534>535<Space direction="vertical">536{(Object.keys(SITE_LICENSE) as Array<Preset>).map((p) => {537const { name, icon, descr } = SITE_LICENSE[p];538return (539<Radio key={p} value={p}>540<span>541<Icon name={icon ?? "arrow-up"} />{" "}542<strong>{name}:</strong> {descr}543</span>544</Radio>545);546})}547</Space>548</Radio.Group>549</Form.Item>550{presetInfo}551</>552);553}554555function renderPresetPanels() {556if (narrow) return renderPresetsNarrow();557558const panels = (Object.keys(SITE_LICENSE) as Array<Preset>).map(559(p, idx) => {560const { name, icon, cpu, ram, disk, uptime, expect, descr, note } =561SITE_LICENSE[p];562const active = preset === p;563return (564<PricingItem565key={idx}566title={name}567icon={icon}568style={{ flex: 1 }}569active={active}570onClick={() => onPresetChange(SITE_LICENSE, p)}571>572<Paragraph>573<strong>{name}</strong> {descr}.574</Paragraph>575<Divider />576<Line amount={cpu} desc={"CPU"} indent={false} />577<Line amount={ram} desc={"RAM"} indent={false} />578<Line amount={disk} desc={"Disk space"} indent={false} />579<Line580amount={displaySiteLicense(uptime)}581desc={renderIdleTimeoutWithHelp("Idle timeout")}582indent={false}583/>584<Divider />585<Paragraph>586<Text type="secondary">587In each project, you will be able to:588</Text>589<ul>590{expect.map((what, idx) => (591<li key={idx}>{what}</li>592))}593</ul>594</Paragraph>595{active && note != null ? (596<>597<Divider />598<Paragraph type="secondary">{note}</Paragraph>599</>600) : undefined}601<Paragraph style={{ marginTop: "20px", textAlign: "center" }}>602<Button603onClick={() => onPresetChange(SITE_LICENSE, p)}604size="large"605type={active ? "primary" : undefined}606>607{name}608</Button>609</Paragraph>610</PricingItem>611);612},613);614return (615<Flex616style={{ width: "100%" }}617justify={"space-between"}618align={"flex-start"}619gap="10px"620>621{panels}622</Flex>623);624}625626function presetExtra() {627return (628<Space ref={presetsRef} direction="vertical">629<div>630{presetIsAdjusted()}631{renderPresetPanels()}632{renderNoPresetWarning()}633</div>634{presetsCommon()}635</Space>636);637}638639function onPresetChange(640preset: { [key: string]: PresetConfig },641val: Preset,642) {643if (val == null || setPreset == null) return;644setPreset(val);645setPresetAdjusted?.(false);646const presetData = preset[val];647if (presetData != null) {648const { cpu, ram, disk, uptime = "short", member = true } = presetData;649form.setFieldsValue({ uptime, member, cpu, ram, disk });650}651onChange();652}653654function detailed() {655return (656<>657{ram()}658{cpu()}659{disk()}660</>661);662}663664function main() {665if (boost) {666return (667<>668<Row>669<Col xs={16} offset={6} style={{ marginBottom: "20px" }}>670<Text type="secondary">671Configure the quotas you want to add on top of your existing672license. E.g. if your license provides a limit of 2 GB of RAM673and you add a matching boost license with 3 GB of RAM, you'll674end up with a total quota limit of 5 GB of RAM.675</Text>676</Col>677</Row>678{detailed()}679</>680);681} else {682switch (source) {683case "site-license":684return (685<Tabs686activeKey={configMode}687onChange={setConfigMode}688type="card"689tabPosition="top"690size="middle"691centered={true}692items={[693{694key: "preset",695label: (696<span>697<Icon name="gears" style={{ marginRight: "5px" }} />698Presets699</span>700),701children: presetExtra(),702},703{704key: "expert",705label: (706<span>707<Icon name="wrench" style={{ marginRight: "5px" }} />708{EXPERT_CONFIG}709</span>710),711children: detailed(),712},713]}714/>715);716case "course":717return renderCoursePresets();718default:719unreachable(source);720}721}722}723724return (725<>726<Divider plain>{title()}</Divider>727{main()}728</>729);730};731732733