mirror of
https://github.com/go-gitea/gitea.git
synced 2026-03-17 14:24:07 +00:00
Instance-wide (global) info banner and maintenance mode (#36571)
The banner allows site operators to communicate important announcements (e.g., maintenance windows, policy updates, service notices) directly within the UI. The maintenance mode only allows admin to access the web UI. * Fix #2345 * Fix #9618 --------- Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
@@ -49,3 +49,11 @@
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.web-banner-content-editor .render-content.render-preview {
|
||||
/* use the styles from ".ui.message" */
|
||||
padding: 1em 1.5em;
|
||||
border: 1px solid var(--color-info-border);
|
||||
background: var(--color-info-bg);
|
||||
color: var(--color-info-text);
|
||||
}
|
||||
|
||||
@@ -14,3 +14,21 @@
|
||||
.ui.container.medium-width {
|
||||
width: 800px;
|
||||
}
|
||||
|
||||
.ui.message.web-banner-container {
|
||||
position: relative;
|
||||
margin: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.ui.message.web-banner-container > .web-banner-content {
|
||||
width: 1280px;
|
||||
max-width: calc(100% - calc(2 * var(--page-margin-x)));
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.ui.message.web-banner-container > button.dismiss-banner {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
top: 15px;
|
||||
}
|
||||
|
||||
41
web_src/js/features/admin/config.test.ts
Normal file
41
web_src/js/features/admin/config.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import {ConfigFormValueMapper} from './config.ts';
|
||||
|
||||
test('ConfigFormValueMapper', () => {
|
||||
document.body.innerHTML = `
|
||||
<form>
|
||||
<input id="checkbox-unrelated" type="checkbox" value="v-unrelated" checked>
|
||||
|
||||
<!-- top-level key -->
|
||||
<input name="k1" type="checkbox" value="v-key-only" data-config-dyn-key="k1" data-config-value-json="true" data-config-value-type="boolean">
|
||||
<input type="hidden" data-config-dyn-key="k2" data-config-value-json='"k2-val"'>
|
||||
<input name="k2">
|
||||
<textarea name="repository.open-with.editor-apps"> a = b\n</textarea>
|
||||
|
||||
<!-- sub key -->
|
||||
<input type="hidden" data-config-dyn-key="struct" data-config-value-json='{"SubBoolean": true, "SubTimestamp": 123456789, "OtherKey": "other-value"}'>
|
||||
<input name="struct.SubBoolean" type="checkbox" data-config-value-type="boolean">
|
||||
<input name="struct.SubTimestamp" type="datetime-local" data-config-value-type="timestamp">
|
||||
<textarea name="struct.NewKey">new-value</textarea>
|
||||
</form>
|
||||
`;
|
||||
|
||||
const form = document.querySelector('form')!;
|
||||
const mapper = new ConfigFormValueMapper(form);
|
||||
mapper.fillFromSystemConfig();
|
||||
const formData = mapper.collectToFormData();
|
||||
const result: Record<string, string> = {};
|
||||
const keys = [], values = [];
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (key === 'key') keys.push(value as string);
|
||||
if (key === 'value') values.push(value as string);
|
||||
}
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
result[keys[i]] = values[i];
|
||||
}
|
||||
expect(result).toEqual({
|
||||
'k1': 'true',
|
||||
'k2': '"k2-val"',
|
||||
'repository.open-with.editor-apps': '[{"DisplayName":"a","OpenURL":"b"}]', // TODO: OPEN-WITH-EDITOR-APP-JSON: it must match backend
|
||||
'struct': '{"SubBoolean":true,"SubTimestamp":123456780,"OtherKey":"other-value","NewKey":"new-value"}',
|
||||
});
|
||||
});
|
||||
@@ -1,24 +1,210 @@
|
||||
import {showTemporaryTooltip} from '../../modules/tippy.ts';
|
||||
import {POST} from '../../modules/fetch.ts';
|
||||
import {registerGlobalInitFunc} from '../../modules/observer.ts';
|
||||
import {queryElems} from '../../utils/dom.ts';
|
||||
import {submitFormFetchAction} from '../common-fetch-action.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
|
||||
export function initAdminConfigs(): void {
|
||||
const elAdminConfig = document.querySelector<HTMLDivElement>('.page-content.admin.config');
|
||||
if (!elAdminConfig) return;
|
||||
function initSystemConfigAutoCheckbox(el: HTMLInputElement) {
|
||||
el.addEventListener('change', async () => {
|
||||
// if the checkbox is inside a form, we assume it's handled by the form submit and do not send an individual request
|
||||
if (el.closest('form')) return;
|
||||
try {
|
||||
const resp = await POST(`${appSubUrl}/-/admin/config`, {
|
||||
data: new URLSearchParams({key: el.getAttribute('data-config-dyn-key')!, value: String(el.checked)}),
|
||||
});
|
||||
const json: Record<string, any> = await resp.json();
|
||||
if (json.errorMessage) throw new Error(json.errorMessage);
|
||||
} catch (ex) {
|
||||
showTemporaryTooltip(el, ex.toString());
|
||||
el.checked = !el.checked;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const el of elAdminConfig.querySelectorAll<HTMLInputElement>('input[type="checkbox"][data-config-dyn-key]')) {
|
||||
el.addEventListener('change', async () => {
|
||||
type GeneralFormFieldElement = HTMLInputElement;
|
||||
|
||||
function unsupportedElement(el: Element): never {
|
||||
// HINT: for future developers: if you need to handle a config that cannot be directly mapped to a form element, you should either:
|
||||
// * Add a "hidden" input to store the value (not configurable)
|
||||
// * Design a new "component" to handle the config
|
||||
throw new Error(`Unsupported config form value mapping for ${el.nodeName} (name=${(el as HTMLInputElement).name},type=${(el as HTMLInputElement).type}), please add more and design carefully`);
|
||||
}
|
||||
|
||||
function requireExplicitValueType(el: Element): never {
|
||||
throw new Error(`Unsupported config form value type for ${el.nodeName} (name=${(el as HTMLInputElement).name},type=${(el as HTMLInputElement).type}), please add explicit value type with "data-config-value-type" attribute`);
|
||||
}
|
||||
|
||||
// try to extract the subKey for the config value from the element name
|
||||
// * return '' if the element name exactly matches the config key, which means the value is directly stored in the element
|
||||
// * return null if the config key not match
|
||||
function extractElemConfigSubKey(el: GeneralFormFieldElement, dynKey: string): string | null {
|
||||
if (el.name === dynKey) return '';
|
||||
if (el.name.startsWith(`${dynKey}.`)) return el.name.slice(dynKey.length + 1); // +1 for the dot
|
||||
return null;
|
||||
}
|
||||
|
||||
// Due to the different design between HTML form elements and the JSON struct of the config values, we need to explicitly define some types.
|
||||
// * checkbox can be used for boolean value, it can also be used for multiple values (array)
|
||||
type ConfigValueType = 'boolean' | 'string' | 'number' | 'timestamp'; // TODO: support more types like array, not used at the moment.
|
||||
|
||||
function toDatetimeLocalValue(unixSeconds: number) {
|
||||
const d = new Date(unixSeconds * 1000);
|
||||
return new Date(d.getTime() - d.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
export class ConfigFormValueMapper {
|
||||
form: HTMLFormElement;
|
||||
presetJsonValues: Record<string, any> = {};
|
||||
presetValueTypes: Record<string, ConfigValueType> = {};
|
||||
|
||||
constructor(form: HTMLFormElement) {
|
||||
this.form = form;
|
||||
for (const el of queryElems<HTMLInputElement>(form, '[data-config-value-json]')) {
|
||||
const dynKey = el.getAttribute('data-config-dyn-key')!;
|
||||
const jsonStr = el.getAttribute('data-config-value-json');
|
||||
try {
|
||||
const resp = await POST(`${appSubUrl}/-/admin/config`, {
|
||||
data: new URLSearchParams({key: el.getAttribute('data-config-dyn-key')!, value: String(el.checked)}),
|
||||
});
|
||||
const json: Record<string, any> = await resp.json();
|
||||
if (json.errorMessage) throw new Error(json.errorMessage);
|
||||
} catch (ex) {
|
||||
showTemporaryTooltip(el, ex.toString());
|
||||
el.checked = !el.checked;
|
||||
this.presetJsonValues[dynKey] = JSON.parse(jsonStr || '{}'); // empty string also is valid, default to an empty object
|
||||
} catch (error) {
|
||||
this.presetJsonValues[dynKey] = {}; // in case the value in database is corrupted, don't break the whole form
|
||||
console.error(`Error parsing JSON for config ${dynKey}:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
for (const el of queryElems<HTMLInputElement>(form, '[data-config-value-type]')) {
|
||||
const valKey = el.getAttribute('data-config-dyn-key') || el.name;
|
||||
this.presetValueTypes[valKey] = el.getAttribute('data-config-value-type')! as ConfigValueType;
|
||||
}
|
||||
}
|
||||
|
||||
// try to assign the config value to the form element, return true if assigned successfully,
|
||||
// otherwise return false (e.g. the element is not related to the config key)
|
||||
assignConfigValueToFormElement(el: GeneralFormFieldElement, dynKey: string, cfgVal: any) {
|
||||
const subKey = extractElemConfigSubKey(el, dynKey);
|
||||
if (subKey === null) return false; // if not match, skip
|
||||
|
||||
const val = subKey ? cfgVal![subKey] : cfgVal;
|
||||
if (val === null) return true; // if name matches, but no value to assign, also succeed because the form element does exist
|
||||
const valType = this.presetValueTypes[el.name];
|
||||
if (el.matches('[type="checkbox"]')) {
|
||||
if (valType !== 'boolean') requireExplicitValueType(el);
|
||||
el.checked = Boolean(val ?? el.checked);
|
||||
} else if (el.matches('[type="datetime-local"]')) {
|
||||
if (valType !== 'timestamp') requireExplicitValueType(el);
|
||||
if (val) el.value = toDatetimeLocalValue(val);
|
||||
} else if (el.matches('textarea')) {
|
||||
el.value = String(val ?? el.value);
|
||||
} else if (el.matches('input') && (el.getAttribute('type') ?? 'text') === 'text') {
|
||||
el.value = String(val ?? el.value);
|
||||
} else {
|
||||
unsupportedElement(el);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
collectConfigValueFromElement(el: GeneralFormFieldElement, _oldVal: any = null) {
|
||||
let val: any;
|
||||
const valType = this.presetValueTypes[el.name];
|
||||
if (el.matches('[type="checkbox"]')) {
|
||||
if (valType !== 'boolean') requireExplicitValueType(el);
|
||||
val = el.checked;
|
||||
// oldVal: for future use when we support array value with checkbox
|
||||
} else if (el.matches('[type="datetime-local"]')) {
|
||||
if (valType !== 'timestamp') requireExplicitValueType(el);
|
||||
val = Math.floor(new Date(el.value).getTime() / 1000) ?? 0; // NaN is fine to JSON.stringify, it becomes null.
|
||||
} else if (el.matches('textarea')) {
|
||||
val = el.value;
|
||||
} else if (el.matches('input') && (el.getAttribute('type') ?? 'text') === 'text') {
|
||||
val = el.value;
|
||||
} else {
|
||||
unsupportedElement(el);
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
collectConfigSubValues(namedElems: Array<GeneralFormFieldElement | null>, dynKey: string, cfgVal: Record<string, any>) {
|
||||
for (let idx = 0; idx < namedElems.length; idx++) {
|
||||
const el = namedElems[idx];
|
||||
if (!el) continue;
|
||||
const subKey = extractElemConfigSubKey(el, dynKey);
|
||||
if (!subKey) continue; // if not match, skip
|
||||
cfgVal[subKey] = this.collectConfigValueFromElement(el, cfgVal[subKey]);
|
||||
namedElems[idx] = null;
|
||||
}
|
||||
}
|
||||
|
||||
fillFromSystemConfig() {
|
||||
for (const [dynKey, cfgVal] of Object.entries(this.presetJsonValues)) {
|
||||
const elems = this.form.querySelectorAll<GeneralFormFieldElement>(`[name^="${CSS.escape(dynKey)}"]`);
|
||||
let assigned = false;
|
||||
for (const el of elems) {
|
||||
if (this.assignConfigValueToFormElement(el, dynKey, cfgVal)) {
|
||||
assigned = true;
|
||||
}
|
||||
}
|
||||
if (!assigned) throw new Error(`Could not find form element for config ${dynKey}, please check the form design and json struct`);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: OPEN-WITH-EDITOR-APP-JSON: need to use the same logic as backend
|
||||
marshalConfigValueOpenWithEditorApps(cfgVal: string): string {
|
||||
const apps: Array<{DisplayName: string, OpenURL: string}> = [];
|
||||
const lines = cfgVal.split('\n');
|
||||
for (const line of lines) {
|
||||
let [displayName, openUrl] = line.split('=', 2);
|
||||
displayName = displayName.trim();
|
||||
openUrl = openUrl?.trim() ?? '';
|
||||
if (!displayName || !openUrl) continue;
|
||||
apps.push({DisplayName: displayName, OpenURL: openUrl});
|
||||
}
|
||||
return JSON.stringify(apps);
|
||||
}
|
||||
|
||||
marshalConfigValue(dynKey: string, cfgVal: any): string {
|
||||
if (dynKey === 'repository.open-with.editor-apps') return this.marshalConfigValueOpenWithEditorApps(cfgVal);
|
||||
return JSON.stringify(cfgVal);
|
||||
}
|
||||
|
||||
collectToFormData(): FormData {
|
||||
const namedElems: Array<GeneralFormFieldElement | null> = [];
|
||||
queryElems(this.form, '[name]', (el) => namedElems.push(el as GeneralFormFieldElement));
|
||||
|
||||
// first, process the config options with sub values, for example:
|
||||
// merge "foo.bar.Enabled", "foo.bar.Message" to "foo.bar"
|
||||
const formData = new FormData();
|
||||
for (const [dynKey, cfgVal] of Object.entries(this.presetJsonValues)) {
|
||||
this.collectConfigSubValues(namedElems, dynKey, cfgVal);
|
||||
formData.append('key', dynKey);
|
||||
formData.append('value', this.marshalConfigValue(dynKey, cfgVal));
|
||||
}
|
||||
|
||||
// now, the namedElems should only contain the config options without sub values,
|
||||
// directly store the value in formData with key as the element name, for example:
|
||||
for (const el of namedElems) {
|
||||
if (!el) continue;
|
||||
const dynKey = el.name;
|
||||
const newVal = this.collectConfigValueFromElement(el);
|
||||
formData.append('key', dynKey);
|
||||
formData.append('value', this.marshalConfigValue(dynKey, newVal));
|
||||
}
|
||||
return formData;
|
||||
}
|
||||
}
|
||||
|
||||
function initSystemConfigForm(form: HTMLFormElement) {
|
||||
const formMapper = new ConfigFormValueMapper(form);
|
||||
formMapper.fillFromSystemConfig();
|
||||
form.addEventListener('submit', async (e) => {
|
||||
if (!form.reportValidity()) return;
|
||||
e.preventDefault();
|
||||
const formData = formMapper.collectToFormData();
|
||||
await submitFormFetchAction(form, {formData});
|
||||
});
|
||||
}
|
||||
|
||||
export function initAdminConfigs(): void {
|
||||
registerGlobalInitFunc('initAdminConfigSettings', (el) => {
|
||||
queryElems(el, 'input[type="checkbox"][data-config-dyn-key]', initSystemConfigAutoCheckbox);
|
||||
queryElems(el, 'form.system-config-form', initSystemConfigForm);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -67,10 +67,15 @@ async function fetchActionDoRequest(actionElem: HTMLElement, url: string, opt: R
|
||||
|
||||
async function onFormFetchActionSubmit(formEl: HTMLFormElement, e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
await submitFormFetchAction(formEl, submitEventSubmitter(e));
|
||||
await submitFormFetchAction(formEl, {formSubmitter: submitEventSubmitter(e)});
|
||||
}
|
||||
|
||||
export async function submitFormFetchAction(formEl: HTMLFormElement, formSubmitter?: HTMLElement) {
|
||||
type SubmitFormFetchActionOpts = {
|
||||
formSubmitter?: HTMLElement;
|
||||
formData?: FormData;
|
||||
};
|
||||
|
||||
export async function submitFormFetchAction(formEl: HTMLFormElement, opts: SubmitFormFetchActionOpts = {}) {
|
||||
if (formEl.classList.contains('is-loading')) return;
|
||||
|
||||
formEl.classList.add('is-loading');
|
||||
@@ -80,8 +85,8 @@ export async function submitFormFetchAction(formEl: HTMLFormElement, formSubmitt
|
||||
|
||||
const formMethod = formEl.getAttribute('method') || 'get';
|
||||
const formActionUrl = formEl.getAttribute('action') || window.location.href;
|
||||
const formData = new FormData(formEl);
|
||||
const [submitterName, submitterValue] = [formSubmitter?.getAttribute('name'), formSubmitter?.getAttribute('value')];
|
||||
const formData = opts.formData ?? new FormData(formEl);
|
||||
const [submitterName, submitterValue] = [opts.formSubmitter?.getAttribute('name'), opts.formSubmitter?.getAttribute('value')];
|
||||
if (submitterName) {
|
||||
formData.append(submitterName, submitterValue || '');
|
||||
}
|
||||
|
||||
@@ -266,8 +266,8 @@ export class ComboMarkdownEditor {
|
||||
addTableButton.addEventListener('click', () => addTablePanelTippy.show());
|
||||
|
||||
addTablePanel.querySelector('.ui.button.primary')!.addEventListener('click', () => {
|
||||
let rows = parseInt(addTablePanel.querySelector<HTMLInputElement>('[name=rows]')!.value);
|
||||
let cols = parseInt(addTablePanel.querySelector<HTMLInputElement>('[name=cols]')!.value);
|
||||
let rows = parseInt(addTablePanel.querySelector<HTMLInputElement>('.add-table-rows')!.value);
|
||||
let cols = parseInt(addTablePanel.querySelector<HTMLInputElement>('.add-table-cols')!.value);
|
||||
rows = Math.max(1, Math.min(100, rows));
|
||||
cols = Math.max(1, Math.min(100, cols));
|
||||
replaceTextareaSelection(this.textarea, `\n${this.generateMarkdownTable(rows, cols)}\n\n`);
|
||||
|
||||
@@ -197,5 +197,5 @@ export function initRepoEditor() {
|
||||
|
||||
export function renderPreviewPanelContent(previewPanel: Element, htmlContent: string) {
|
||||
// the content is from the server, so it is safe to use innerHTML
|
||||
previewPanel.innerHTML = html`<div class="render-content markup">${htmlRaw(htmlContent)}</div>`;
|
||||
previewPanel.innerHTML = html`<div class="render-content render-preview markup">${htmlRaw(htmlContent)}</div>`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user