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