Path: blob/master/src/packages/frontend/admin/site-settings/index.tsx
1496 views
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45import {6Alert,7Tag as AntdTag,8Button,9Col,10Input,11InputRef,12Modal,13Row,14} from "antd";15import { isEqual } from "lodash";16import { useEffect, useMemo, useRef, useState } from "react";17import { Well } from "@cocalc/frontend/antd-bootstrap";18import { redux } from "@cocalc/frontend/app-framework";19import useCounter from "@cocalc/frontend/app-framework/counter-hook";20import { Gap, Icon, Loading, Paragraph } from "@cocalc/frontend/components";21import { query } from "@cocalc/frontend/frame-editors/generic/client";22import { TAGS, Tag } from "@cocalc/util/db-schema/site-defaults";23import { EXTRAS } from "@cocalc/util/db-schema/site-settings-extras";24import { deep_copy, keys } from "@cocalc/util/misc";25import { site_settings_conf } from "@cocalc/util/schema";26import { RenderRow } from "./render-row";27import { Data, IsReadonly, State } from "./types";28import {29toCustomOpenAIModel,30toOllamaModel,31} from "@cocalc/util/db-schema/llm-utils";32import ShowError from "@cocalc/frontend/components/error";3334const { CheckableTag } = AntdTag;3536export default function SiteSettings({ close }) {37const { inc: change } = useCounter();38const testEmailRef = useRef<InputRef>(null);39const [_, setDisableTests] = useState<boolean>(false);40const [state, setState] = useState<State>("load");41const [error, setError] = useState<string>("");42const [data, setData] = useState<Data | null>(null);43const [filterStr, setFilterStr] = useState<string>("");44const [filterTag, setFilterTag] = useState<Tag | null>(null);45const editedRef = useRef<Data | null>(null);46const savedRef = useRef<Data | null>(null);47const [isReadonly, setIsReadonly] = useState<IsReadonly | null>(null);48const update = () => {49setData(deep_copy(editedRef.current));50};5152useEffect(() => {53load();54}, []);5556async function load(): Promise<void> {57setState("load");58let result: any;59try {60result = await query({61query: {62site_settings: [{ name: null, value: null, readonly: null }],63},64});65} catch (err) {66setState("error");67setError(`${err} – query error, please try again…`);68return;69}70const data: { [name: string]: string } = {};71const isReadonly: IsReadonly = {};72for (const x of result.query.site_settings) {73data[x.name] = x.value;74isReadonly[x.name] = !!x.readonly;75}76setState("edit");77setData(data);78setIsReadonly(isReadonly);79editedRef.current = deep_copy(data);80savedRef.current = deep_copy(data);81setDisableTests(false);82}8384// returns true if the given settings key is a header85function isHeader(name: string): boolean {86return (87EXTRAS[name]?.type == "header" ||88site_settings_conf[name]?.type == "header"89);90}9192function isModified(name: string) {93if (data == null || editedRef.current == null || savedRef.current == null)94return false;9596const edited = editedRef.current[name];97const saved = savedRef.current[name];98return !isEqual(edited, saved);99}100101function getModifiedSettings() {102if (data == null || editedRef.current == null || savedRef.current == null)103return [];104105const ret: { name: string; value: string }[] = [];106for (const name in editedRef.current) {107const value = editedRef.current[name];108if (isHeader[name]) continue;109if (isModified(name)) {110ret.push({ name, value });111}112}113ret.sort((a, b) => a.name.localeCompare(b.name));114return ret;115}116117async function store(): Promise<void> {118if (data == null || editedRef.current == null || savedRef.current == null)119return;120for (const { name, value } of getModifiedSettings()) {121try {122await query({123query: {124site_settings: { name, value },125},126});127savedRef.current[name] = value;128} catch (err) {129setState("error");130setError(err);131return;132}133}134// success save of everything, so clear error message135setError("");136}137138async function saveAll(): Promise<void> {139// list the names of changed settings140const content = (141<Paragraph>142<ul>143{getModifiedSettings().map(({ name, value }) => {144const label =145(site_settings_conf[name] ?? EXTRAS[name]).name ?? name;146return (147<li key={name}>148<b>{label}</b>: <code>{value}</code>149</li>150);151})}152</ul>153</Paragraph>154);155156setState("save");157158Modal.confirm({159title: "Confirm changing the following settings?",160icon: <Icon name="warning" />,161width: 700,162content,163onOk() {164return new Promise<void>(async (done, error) => {165try {166await store();167setState("edit");168await load();169done();170} catch (err) {171error(err);172}173});174},175onCancel() {176close();177},178});179}180181// this is the small grene button, there is no confirmation182async function saveSingleSetting(name: string): Promise<void> {183if (data == null || editedRef.current == null || savedRef.current == null)184return;185const value = editedRef.current[name];186setState("save");187try {188await query({189query: {190site_settings: { name, value },191},192});193savedRef.current[name] = value;194setState("edit");195} catch (err) {196setState("error");197setError(err);198return;199}200}201202function SaveButton() {203if (data == null || savedRef.current == null) return null;204let disabled: boolean = true;205for (const name in { ...savedRef.current, ...data }) {206const value = savedRef.current[name];207if (!isEqual(value, data[name])) {208disabled = false;209break;210}211}212213return (214<Button type="primary" disabled={disabled} onClick={saveAll}>215{state == "save" ? <Loading text="Saving" /> : "Save All"}216</Button>217);218}219220function CancelButton() {221return <Button onClick={close}>Cancel</Button>;222}223224function onChangeEntry(name: string, val: string) {225if (editedRef.current == null) return;226editedRef.current[name] = val;227change();228update();229}230231function onJsonEntryChange(name: string, new_val?: string) {232if (editedRef.current == null) return;233try {234if (new_val == null) return;235JSON.parse(new_val); // does it throw?236editedRef.current[name] = new_val;237} catch (err) {238// TODO: obviously this should be visible to the user! Gees.239console.warn(`Error saving json of ${name}`, err.message);240}241change();242update(); // without that, the "green save button" does not show up. this makes it consistent.243}244245function Buttons() {246return (247<div>248<CancelButton />249<Gap />250<SaveButton />251</div>252);253}254255function Tests() {256return (257<div style={{ marginBottom: "1rem" }}>258<strong>Tests:</strong>259<Gap />260Email:261<Gap />262<Input263style={{ width: "auto" }}264defaultValue={redux.getStore("account").get("email_address")}265ref={testEmailRef}266/>267</div>268);269}270271function Warning() {272return (273<div>274<Alert275type="warning"276style={{277maxWidth: "800px",278margin: "0 auto 20px auto",279border: "1px solid lightgrey",280}}281message={282<div>283<i>284<ul style={{ marginBottom: 0 }}>285<li>286Most settings will take effect within 1 minute of save;287however, some might require restarting the server.288</li>289<li>290If the box containing a setting has a red border, that means291the value that you entered is invalid.292</li>293</ul>294</i>295</div>296}297/>298</div>299);300}301302const editRows = useMemo(() => {303return (304<>305{[site_settings_conf, EXTRAS].map((configData) =>306keys(configData).map((name) => {307const conf = configData[name];308309// This is a weird special case, where the valid value depends on other values310if (name === "default_llm") {311const c = site_settings_conf.selectable_llms;312const llms = c.to_val?.(data?.selectable_llms ?? c.default) ?? [];313const o = EXTRAS.ollama_configuration;314const oll = Object.keys(315o.to_val?.(data?.ollama_configuration) ?? {},316).map(toOllamaModel);317const a = EXTRAS.ollama_configuration;318const oaic = data?.custom_openai_configuration;319const oai = (320oaic != null ? Object.keys(a.to_val?.(oaic) ?? {}) : []321).map(toCustomOpenAIModel);322if (Array.isArray(llms)) {323conf.valid = [...llms, ...oll, ...oai];324}325}326327return (328<RenderRow329filterStr={filterStr}330filterTag={filterTag}331key={name}332name={name}333conf={conf}334data={data}335update={update}336isReadonly={isReadonly}337onChangeEntry={onChangeEntry}338onJsonEntryChange={onJsonEntryChange}339isModified={isModified}340isHeader={isHeader(name)}341saveSingleSetting={saveSingleSetting}342/>343);344}),345)}346</>347);348}, [state, data, filterStr, filterTag]);349350const activeFilter = !filterStr.trim() || filterTag;351352return (353<div>354{state == "save" && (355<Loading356delay={1000}357style={{ float: "right", fontSize: "15pt" }}358text="Saving site configuration..."359/>360)}361{state == "load" && (362<Loading363delay={1000}364style={{ float: "right", fontSize: "15pt" }}365text="Loading site configuration..."366/>367)}368<Well369style={{370margin: "auto",371maxWidth: "80%",372}}373>374<Warning />375<ShowError376error={error}377setError={setError}378style={{ margin: "30px auto", maxWidth: "800px" }}379/>380<Row key="filter">381<Col span={12}>382<Buttons />383</Col>384<Col span={12}>385<Input.Search386style={{ marginBottom: "5px" }}387allowClear388value={filterStr}389placeholder="Filter Site Settings..."390onChange={(e) => setFilterStr(e.target.value)}391/>392{[...TAGS].sort().map((name) => (393<CheckableTag394key={name}395style={{ cursor: "pointer" }}396checked={filterTag === name}397onChange={(checked) => {398if (checked) {399setFilterTag(name);400} else {401setFilterTag(null);402}403}}404>405{name}406</CheckableTag>407))}408</Col>409</Row>410{editRows}411<Gap />412{!activeFilter && <Tests />}413{!activeFilter && <Buttons />}414{activeFilter ? (415<Alert416showIcon417type="warning"418message={`Some items may be hidden by the search filter or a selected tag.`}419/>420) : undefined}421</Well>422</div>423);424}425426427