Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Path: blob/master/src/packages/frontend/customize.tsx
Views: 687
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45// Site Customize -- dynamically customize the look and configuration6// of CoCalc for the client.78import { fromJS, List, Map } from "immutable";9import { join } from "path";10import { useIntl } from "react-intl";1112import {13Actions,14rclass,15React,16redux,17Redux,18rtypes,19Store,20TypedMap,21useTypedRedux,22} from "@cocalc/frontend/app-framework";23import {24A,25build_date,26Gap,27Loading,28r_join,29smc_git_rev,30smc_version,31UNIT,32} from "@cocalc/frontend/components";33import { getGoogleCloudImages, getImages } from "@cocalc/frontend/compute/api";34import { appBasePath } from "@cocalc/frontend/customize/app-base-path";35import { labels, Locale } from "@cocalc/frontend/i18n";36import { callback2, retry_until_success } from "@cocalc/util/async-utils";37import {38ComputeImage,39FALLBACK_ONPREM_ENV,40FALLBACK_SOFTWARE_ENV,41} from "@cocalc/util/compute-images";42import { DEFAULT_COMPUTE_IMAGE } from "@cocalc/util/db-schema";43import type {44GoogleCloudImages,45Images,46} from "@cocalc/util/db-schema/compute-servers";47import { LLMServicesAvailable } from "@cocalc/util/db-schema/llm-utils";48import {49Config,50KUCALC_COCALC_COM,51KUCALC_DISABLED,52KUCALC_ON_PREMISES,53site_settings_conf,54} from "@cocalc/util/db-schema/site-defaults";55import { deep_copy, dict, YEAR } from "@cocalc/util/misc";56import { reuseInFlight } from "@cocalc/util/reuse-in-flight";57import { sanitizeSoftwareEnv } from "@cocalc/util/sanitize-software-envs";58import * as theme from "@cocalc/util/theme";59import { CustomLLMPublic } from "@cocalc/util/types/llm";60import { DefaultQuotaSetting, Upgrades } from "@cocalc/util/upgrades/quota";61export { TermsOfService } from "@cocalc/frontend/customize/terms-of-service";6263// this sets UI modes for using a kubernetes based back-end64// 'yes' (historic value) equals 'cocalc.com'65function validate_kucalc(k?): string {66if (k == null) return KUCALC_DISABLED;67const val = k.trim().toLowerCase();68if ([KUCALC_DISABLED, KUCALC_COCALC_COM, KUCALC_ON_PREMISES].includes(val)) {69return val;70}71console.warn(`site settings customize: invalid kucalc value ${k}`);72return KUCALC_DISABLED;73}7475// populate all default key/values in the "customize" store76const defaultKeyVals: [string, string | string[]][] = [];77for (const k in site_settings_conf) {78const v: Config = site_settings_conf[k];79const value: any =80typeof v.to_val === "function" ? v.to_val(v.default) : v.default;81defaultKeyVals.push([k, value]);82}83const defaults: any = dict(defaultKeyVals);84defaults.is_commercial = defaults.commercial;85defaults._is_configured = false; // will be true after set via call to server8687// CustomizeState is maybe extension of what's in SiteSettings88// so maybe there is a more clever way like this to do it than89// what I did below.90// type SiteSettings = { [k in keyof SiteSettingsConfig]: any };9192export type SoftwareEnvironments = TypedMap<{93groups: List<string>;94default: string;95environments: Map<string, TypedMap<ComputeImage>>;96}>;9798export interface CustomizeState {99time: number; // this will always get set once customize has loaded.100is_commercial: boolean;101openai_enabled: boolean;102google_vertexai_enabled: boolean;103mistral_enabled: boolean;104anthropic_enabled: boolean;105ollama_enabled: boolean;106custom_openai_enabled: boolean;107neural_search_enabled: boolean;108datastore: boolean;109ssh_gateway: boolean;110ssh_gateway_dns: string; // e.g. "ssh.cocalc.com"111ssh_gateway_fingerprint: string; // e.g. "SHA256:a8284..."112account_creation_email_instructions: string;113commercial: boolean;114default_quotas: TypedMap<DefaultQuotaSetting>;115dns: string; // e.g. "cocalc.com"116email_enabled: false;117email_signup: boolean;118anonymous_signup: boolean;119google_analytics: string;120help_email: string;121iframe_comm_hosts: string[];122index_info_html: string;123is_cocalc_com: boolean;124is_personal: boolean;125kucalc: string;126logo_rectangular: string;127logo_square: string;128max_upgrades: TypedMap<Partial<Upgrades>>;129130// Commercialization parameters.131// Be sure to also update disableCommercializationParameters132// below if you change these:133nonfree_countries?: List<string>;134limit_free_project_uptime: number; // minutes135require_license_to_create_project?: boolean;136unlicensed_project_collaborator_limit?: number;137unlicensed_project_timetravel_limit?: number;138139onprem_quota_heading: string;140organization_email: string;141organization_name: string;142organization_url: string;143share_server: boolean;144site_description: string;145site_name: string;146splash_image: string;147terms_of_service: string;148terms_of_service_url: string;149theming: boolean;150verify_emails: false;151version_min_browser: number;152version_min_project: number;153version_recommended_browser: number;154versions: string;155// extra setting, injected by the hub, not the DB156// we expect this to follow "ISO 3166-1 Alpha 2" + K1 (Tor network) + XX (unknown)157// use a lib like https://github.com/michaelwittig/node-i18n-iso-countries158country: string;159// flag to signal data stored in the Store.160software: SoftwareEnvironments;161_is_configured: boolean;162jupyter_api_enabled?: boolean;163164compute_servers_enabled?: boolean;165["compute_servers_google-cloud_enabled"]?: boolean;166compute_servers_lambda_enabled?: boolean;167compute_servers_dns_enabled?: boolean;168compute_servers_dns?: string;169compute_servers_images?: TypedMap<Images> | string | null;170compute_servers_images_google?: TypedMap<GoogleCloudImages> | string | null;171172llm_markup: number;173174ollama?: TypedMap<{ [key: string]: TypedMap<CustomLLMPublic> }>;175custom_openai?: TypedMap<{ [key: string]: TypedMap<CustomLLMPublic> }>;176selectable_llms: List<string>;177default_llm?: string;178user_defined_llm: boolean;179180insecure_test_mode?: boolean;181182i18n?: List<Locale>;183}184185export class CustomizeStore extends Store<CustomizeState> {186async until_configured(): Promise<void> {187if (this.get("_is_configured")) return;188await callback2(this.wait, { until: () => this.get("_is_configured") });189}190191get_iframe_comm_hosts(): string[] {192const hosts = this.get("iframe_comm_hosts");193if (hosts == null) return [];194return hosts.toJS();195}196197async getDefaultComputeImage(): Promise<string> {198await this.until_configured();199return this.getIn(["software", "default"]) ?? DEFAULT_COMPUTE_IMAGE;200}201202getEnabledLLMs(): LLMServicesAvailable {203return {204openai: this.get("openai_enabled"),205google: this.get("google_vertexai_enabled"),206ollama: this.get("ollama_enabled"),207custom_openai: this.get("custom_openai_enabled"),208mistralai: this.get("mistral_enabled"),209anthropic: this.get("anthropic_enabled"),210user: this.get("user_defined_llm"),211};212}213}214215export class CustomizeActions extends Actions<CustomizeState> {216// reload is admin only217updateComputeServerImages = reuseInFlight(async (reload?) => {218if (!store.get("compute_servers_enabled")) {219this.setState({ compute_servers_images: fromJS({}) as any });220return;221}222try {223this.setState({224compute_servers_images: fromJS(await getImages(reload)) as any,225});226} catch (err) {227this.setState({ compute_servers_images: `${err}` });228}229});230231updateComputeServerImagesGoogle = reuseInFlight(async (reload?) => {232if (!store.get("compute_servers_google-cloud_enabled")) {233this.setState({ compute_servers_images_google: fromJS({}) as any });234return;235}236try {237this.setState({238compute_servers_images_google: fromJS(239await getGoogleCloudImages(reload),240) as any,241});242} catch (err) {243this.setState({ compute_servers_images_google: `${err}` });244}245});246247// this is used for accounts that have legacy upgrades248disableCommercializationParameters = () => {249this.setState({250limit_free_project_uptime: undefined,251require_license_to_create_project: undefined,252unlicensed_project_collaborator_limit: undefined,253unlicensed_project_timetravel_limit: undefined,254});255};256}257258export const store = redux.createStore("customize", CustomizeStore, defaults);259const actions = redux.createActions("customize", CustomizeActions);260// really simple way to have a default value -- gets changed below once the $?.get returns.261actions.setState({ is_commercial: true, ssh_gateway: true });262263// If we are running in the browser, then we customize the schema. This also gets run on the backend264// to generate static content, which can't be customized.265export let commercial: boolean = defaults.is_commercial;266267// For now, hopefully not used (this was the old approach).268// in the future we might want to reload the configuration, though.269// Note that this *is* clearly used as a fallback below though...!270async function init_customize() {271if (typeof process != "undefined") {272// running in node.js273return;274}275let customize;276await retry_until_success({277f: async () => {278const url = join(appBasePath, "customize");279try {280customize = await (await fetch(url)).json();281} catch (err) {282const msg = `fetch /customize failed -- retrying - ${err}`;283console.warn(msg);284throw new Error(msg);285}286},287start_delay: 2000,288max_delay: 30000,289});290291const {292configuration,293registration,294strategies,295software = null,296ollama = null, // the derived public information297custom_openai = null,298} = customize;299process_kucalc(configuration);300process_software(software, configuration.is_cocalc_com);301process_customize(configuration); // this sets _is_configured to true302process_ollama(ollama);303process_custom_openai(custom_openai);304const actions = redux.getActions("account");305// Which account creation strategies we support.306actions.setState({ strategies });307// Set whether or not a registration token is required when creating account.308actions.setState({ token: !!registration });309}310311init_customize();312313function process_ollama(ollama?) {314if (!ollama) return;315actions.setState({ ollama: fromJS(ollama) });316}317318function process_custom_openai(custom_openai?) {319if (!custom_openai) return;320actions.setState({ custom_openai: fromJS(custom_openai) });321}322323function process_kucalc(obj) {324// TODO make this a to_val function in site_settings_conf.kucalc325obj.kucalc = validate_kucalc(obj.kucalc);326obj.is_cocalc_com = obj.kucalc == KUCALC_COCALC_COM;327}328329function process_customize(obj) {330const obj_orig = deep_copy(obj);331for (const k in site_settings_conf) {332const v = site_settings_conf[k];333obj[k] =334obj[k] != null ? obj[k] : v.to_val?.(v.default, obj_orig) ?? v.default;335}336// the llm markup special case337obj.llm_markup = obj_orig._llm_markup ?? 30;338339// always set time, so other code can know for sure that customize was loaded.340// it also might be helpful to know when341obj["time"] = Date.now();342set_customize(obj);343}344345// "obj" are the already processed values from the database346// this function is also used by hub-landing!347function set_customize(obj) {348// console.log('set_customize obj=\n', JSON.stringify(obj, null, 2));349350// set some special cases, backwards compatibility351commercial = obj.is_commercial = obj.commercial;352353obj._is_configured = true;354actions.setState(obj);355}356357function process_software(software, is_cocalc_com) {358const dbg = (...msg) => console.log("sanitizeSoftwareEnv:", ...msg);359if (software != null) {360// this checks the data coming in from the "/customize" endpoint.361// Next step is to convert it to immutable and store it in the customize store.362software = sanitizeSoftwareEnv({ software, purpose: "webapp" }, dbg);363actions.setState({ software });364} else {365if (is_cocalc_com) {366actions.setState({ software: fromJS(FALLBACK_SOFTWARE_ENV) as any });367} else {368software = sanitizeSoftwareEnv(369{ software: FALLBACK_ONPREM_ENV, purpose: "webapp" },370dbg,371);372actions.setState({ software });373}374}375}376377interface HelpEmailLink {378text?: React.ReactNode;379color?: string;380}381382export const HelpEmailLink: React.FC<HelpEmailLink> = React.memo(383(props: HelpEmailLink) => {384const { text, color } = props;385386const help_email = useTypedRedux("customize", "help_email");387const _is_configured = useTypedRedux("customize", "_is_configured");388389const style: React.CSSProperties = {};390if (color != null) {391style.color = color;392}393394if (_is_configured) {395if (help_email?.length > 0) {396return (397<A href={`mailto:${help_email}`} style={style}>398{text ?? help_email}399</A>400);401} else {402return (403<span>404<em>405{"["}not configured{"]"}406</em>407</span>408);409}410} else {411return <Loading style={{ display: "inline" }} />;412}413},414);415416export const SiteName: React.FC = React.memo(() => {417const site_name = useTypedRedux("customize", "site_name");418419if (site_name != null) {420return <span>{site_name}</span>;421} else {422return <Loading style={{ display: "inline" }} />;423}424});425426interface SiteDescriptionProps {427style?: React.CSSProperties;428site_description?: string;429}430431const SiteDescription0 = rclass<{ style?: React.CSSProperties }>(432class SiteDescription extends React.Component<SiteDescriptionProps> {433public static reduxProps() {434return {435customize: {436site_description: rtypes.string,437},438};439}440441public render(): JSX.Element {442const style =443this.props.style != undefined444? this.props.style445: { color: "#666", fontSize: "16px" };446if (this.props.site_description != undefined) {447return <span style={style}>{this.props.site_description}</span>;448} else {449return <Loading style={{ display: "inline" }} />;450}451}452},453);454455// TODO: not used?456export function SiteDescription({ style }: { style?: React.CSSProperties }) {457return (458<Redux>459<SiteDescription0 style={style} />460</Redux>461);462}463464// This generalizes the above in order to pick any selected string value465interface CustomizeStringProps {466name: string;467}468interface CustomizeStringReduxProps {469site_name: string;470site_description: string;471terms_of_service: string;472account_creation_email_instructions: string;473help_email: string;474logo_square: string;475logo_rectangular: string;476splash_image: string;477index_info_html: string;478terms_of_service_url: string;479organization_name: string;480organization_email: string;481organization_url: string;482google_analytics: string;483}484485const CustomizeStringElement = rclass<CustomizeStringProps>(486class CustomizeStringComponent extends React.Component<487CustomizeStringReduxProps & CustomizeStringProps488> {489public static reduxProps = () => {490return {491customize: {492site_name: rtypes.string,493site_description: rtypes.string,494terms_of_service: rtypes.string,495account_creation_email_instructions: rtypes.string,496help_email: rtypes.string,497logo_square: rtypes.string,498logo_rectangular: rtypes.string,499splash_image: rtypes.string,500index_info_html: rtypes.string,501terms_of_service_url: rtypes.string,502organization_name: rtypes.string,503organization_email: rtypes.string,504organization_url: rtypes.string,505google_analytics: rtypes.string,506},507};508};509510shouldComponentUpdate(next) {511if (this.props[this.props.name] == null) return true;512return this.props[this.props.name] != next[this.props.name];513}514515render() {516return <span>{this.props[this.props.name]}</span>;517}518},519);520521// TODO: not used?522export function CustomizeString({ name }: CustomizeStringProps) {523return (524<Redux>525<CustomizeStringElement name={name} />526</Redux>527);528}529530// TODO also make this configurable? Needed in the <Footer/> and maybe elsewhere …531export const CompanyName = function CompanyName() {532return <span>{theme.COMPANY_NAME}</span>;533};534535interface AccountCreationEmailInstructionsProps {536account_creation_email_instructions: string;537}538539const AccountCreationEmailInstructions0 = rclass<{}>(540class AccountCreationEmailInstructions extends React.Component<AccountCreationEmailInstructionsProps> {541public static reduxProps = () => {542return {543customize: {544account_creation_email_instructions: rtypes.string,545},546};547};548549render() {550return (551<h3 style={{ marginTop: 0, textAlign: "center" }}>552{this.props.account_creation_email_instructions}553</h3>554);555}556},557);558559// TODO is this used?560export function AccountCreationEmailInstructions() {561return (562<Redux>563<AccountCreationEmailInstructions0 />564</Redux>565);566}567568export const Footer: React.FC = React.memo(() => {569const intl = useIntl();570const on = useTypedRedux("customize", "organization_name");571const tos = useTypedRedux("customize", "terms_of_service_url");572573const organizationName = on.length > 0 ? on : theme.COMPANY_NAME;574const TOSurl = tos.length > 0 ? tos : PolicyTOSPageUrl;575const webappVersionInfo =576`Version ${smc_version} @ ${build_date}` + ` | ${smc_git_rev.slice(0, 8)}`;577const style: React.CSSProperties = {578color: "gray",579textAlign: "center",580paddingBottom: `${UNIT}px`,581};582583const systemStatus = intl.formatMessage({584id: "customize.footer.system-status",585defaultMessage: "System Status",586});587588const name = intl.formatMessage(589{590id: "customize.footer.name",591defaultMessage: "{name} by {organizationName}",592},593{594name: <SiteName />,595organizationName,596},597);598599function contents() {600const elements = [601<A key="name" href={appBasePath}>602{name}603</A>,604<A key="status" href={SystemStatusUrl}>605{systemStatus}606</A>,607<A key="tos" href={TOSurl}>608{intl.formatMessage(labels.terms_of_service)}609</A>,610<HelpEmailLink key="help" />,611<span key="year" title={webappVersionInfo}>612© {YEAR}613</span>,614];615return r_join(elements, <> · </>);616}617618return (619<footer style={style}>620<hr />621<Gap />622{contents()}623</footer>624);625});626627// first step of centralizing these URLs in one place → collecting all such pages into one628// react-class with a 'type' prop is the next step (TODO)629// then consolidate this with the existing site-settings database (e.g. TOS above is one fixed HTML string with an anchor)630631export const PolicyIndexPageUrl = join(appBasePath, "policies");632export const PolicyPricingPageUrl = join(appBasePath, "pricing");633export const PolicyPrivacyPageUrl = join(appBasePath, "policies/privacy");634export const PolicyCopyrightPageUrl = join(appBasePath, "policies/copyright");635export const PolicyTOSPageUrl = join(appBasePath, "policies/terms");636export const SystemStatusUrl = join(appBasePath, "info/status");637export const PAYGODocsUrl = "https://doc.cocalc.com/paygo.html";638639// 1. Google analytics640async function setup_google_analytics(w) {641// init_analytics already makes sure store is configured642const ga4 = store.get("google_analytics");643if (!ga4) return;644645// for commercial setup, enable conversion tracking...646// the gtag initialization647w.dataLayer = w.dataLayer || [];648w.gtag = function () {649w.dataLayer.push(arguments);650};651w.gtag("js", new Date());652w.gtag("config", `"${ga4}"`);653// load tagmanager654const gtag = w.document.createElement("script");655gtag.src = `https://www.googletagmanager.com/gtag/js?id=${ga4}`;656gtag.async = true;657gtag.defer = true;658w.document.getElementsByTagName("head")[0].appendChild(gtag);659}660661// 2. CoCalc analytics662function setup_cocalc_analytics(w) {663// init_analytics already makes sure store is configured664const ctag = w.document.createElement("script");665ctag.src = join(appBasePath, "analytics.js?fqd=false");666ctag.async = true;667ctag.defer = true;668w.document.getElementsByTagName("head")[0].appendChild(ctag);669}670671async function init_analytics() {672await store.until_configured();673if (!store.get("is_commercial")) return;674675let w: any;676try {677w = window;678} catch (_err) {679// Make it so this code can be run on the backend...680return;681}682if (w?.document == null) {683// Double check that this code can be run on the backend (not in a browser).684// see https://github.com/sagemathinc/cocalc-landing/issues/2685return;686}687688await setup_google_analytics(w);689await setup_cocalc_analytics(w);690}691692init_analytics();693694695