Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion src/api/platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,16 @@ def handle(self, method, path, payload, headers, client) -> APIResponse:
return self._deliveries_page_endpoint(method, actor, int(delivery_page.group(1)))
if path == "/api/v2/audit-events":
return self._audit_endpoint(method, actor)
audit_page_size = re.fullmatch(
r"/api/v2/audit-events/page/(\d+)/size/(\d+)", path
)
if audit_page_size:
return self._audit_page_endpoint(
method,
actor,
int(audit_page_size.group(1)),
int(audit_page_size.group(2)),
)
audit_page = re.fullmatch(r"/api/v2/audit-events/page/(\d+)", path)
if audit_page:
return self._audit_page_endpoint(method, actor, int(audit_page.group(1)))
Expand Down Expand Up @@ -762,10 +772,14 @@ def _audit_endpoint(self, method, actor) -> APIResponse:
events = self.audit.list_visible(actor, limit=500)
return APIResponse(200, {"audit_events": [self._audit(item) for item in events]})

def _audit_page_endpoint(self, method, actor, page) -> APIResponse:
_AUDIT_PAGE_SIZES = (25, 50, 100, 150, 250, 500)

def _audit_page_endpoint(self, method, actor, page, size=None) -> APIResponse:
if method != "GET":
return self._method_not_allowed("GET")
page_size = 25
if size is not None and int(size) in self._AUDIT_PAGE_SIZES:
page_size = int(size)
total = self.audit.count_visible(actor)
total_pages = max(1, (total + page_size - 1) // page_size)
current = min(max(1, int(page)), total_pages)
Expand Down
10 changes: 8 additions & 2 deletions src/api/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,14 @@ def validate_config(data) -> list[str]:
webui.get("enforce_https"), bool
):
errors.append("webui.enforce_https must be a boolean")
if "language" in webui and webui.get("language") not in {"en-GB", "pt-PT"}:
errors.append("webui.language must be en-GB or pt-PT")
supported_languages = {
"en-GB", "en-US", "pt-PT", "pt-BR", "es-ES", "fr-FR",
"de-DE", "it-IT", "nl-NL", "pl-PL", "cs-CZ", "ro-RO",
"sv-SE", "da-DK", "nb-NO", "fi-FI", "el-GR", "tr-TR",
"ru-RU", "uk-UA", "ja-JP", "zh-CN",
}
if "language" in webui and webui.get("language") not in supported_languages:
errors.append("webui.language must be a supported Regional Settings locale")
source_categories = webui.get("source_categories", {})
if not isinstance(source_categories, dict):
errors.append("webui.source_categories must be an object")
Expand Down
30 changes: 28 additions & 2 deletions src/storage/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,8 +234,34 @@ def _regional(value: dict) -> dict:
except (ZoneInfoNotFoundError, ValueError):
raise ValueError("timezone must be a valid IANA timezone") from None
language = str(value.get("language") or "").strip()
if language not in {"en-GB", "en-US", "pt-PT", "pt-BR"}:
raise ValueError("language must be en-GB, en-US, pt-PT, or pt-BR")
supported_languages = {
"en-GB",
"en-US",
"pt-PT",
"pt-BR",
"es-ES",
"fr-FR",
"de-DE",
"it-IT",
"nl-NL",
"pl-PL",
"cs-CZ",
"ro-RO",
"sv-SE",
"da-DK",
"nb-NO",
"fi-FI",
"el-GR",
"tr-TR",
"ru-RU",
"uk-UA",
"ja-JP",
"zh-CN",
}
if language not in supported_languages:
raise ValueError(
"language must be one of the supported Regional Settings locales"
)
time_format = str(value.get("time_format") or "").strip()
if time_format not in {"12", "24"}:
raise ValueError("time format must be 12 or 24")
Expand Down
63 changes: 61 additions & 2 deletions src/webui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,30 @@ const SOURCE_CATEGORIES = {
security: { key: "security", label: "Security" },
generic: { key: "generic", label: "Generic" },
};
const LANGUAGE_DEFAULT_TIMEZONES = {
"en-GB": "Europe/London",
"en-US": "America/New_York",
"pt-PT": "Europe/Lisbon",
"pt-BR": "America/Sao_Paulo",
"es-ES": "Europe/Madrid",
"fr-FR": "Europe/Paris",
"de-DE": "Europe/Berlin",
"it-IT": "Europe/Rome",
"nl-NL": "Europe/Amsterdam",
"pl-PL": "Europe/Warsaw",
"cs-CZ": "Europe/Prague",
"ro-RO": "Europe/Bucharest",
"sv-SE": "Europe/Stockholm",
"da-DK": "Europe/Copenhagen",
"nb-NO": "Europe/Oslo",
"fi-FI": "Europe/Helsinki",
"el-GR": "Europe/Athens",
"tr-TR": "Europe/Istanbul",
"ru-RU": "Europe/Moscow",
"uk-UA": "Europe/Kyiv",
"ja-JP": "Asia/Tokyo",
"zh-CN": "Asia/Shanghai",
};
const PT_TRANSLATIONS = {
"Dashboard": "Painel",
"Overview": "Visão geral",
Expand Down Expand Up @@ -1684,14 +1708,20 @@ function renderPreferences() {
document.documentElement.lang = state.preferences.language || "en-GB";
}

function applyLanguageDefaultTimezone() {
const language = byId("preference-language").value;
const timezone = LANGUAGE_DEFAULT_TIMEZONES[language];
if (timezone) byId("preference-timezone").value = timezone;
}

async function savePreferences(event) {
event.preventDefault();
try {
const response = await request("/preferences", {
method: "PUT",
body: {
language: byId("preference-language").value,
timezone: byId("preference-timezone").value.trim(),
timezone: byId("preference-timezone").value,
time_format: byId("preference-time-format").value,
},
});
Expand Down Expand Up @@ -1882,13 +1912,22 @@ async function restartPlatform(event) {
}
}

function updateAvatarSaveState(available) {
const save = byId("avatar-save");
if (!save) return;
const enabled = Boolean(available);
save.hidden = !enabled;
save.disabled = !enabled;
}

async function saveAvatar(event) {
event.preventDefault();
if (!state.avatarEditor.image) {
toast("Choose a picture first.", "error");
return;
}
try {
updateAvatarSaveState(false);
const imageData = byId("avatar-canvas").toDataURL("image/png");
const response = await request("/account/avatar", { method: "PUT", body: { image_data: imageData } });
state.user = response.user;
Expand All @@ -1900,8 +1939,10 @@ async function saveAvatar(event) {
state.avatarEditor.image.close();
}
state.avatarEditor.image = null;
updateAvatarSaveState(false);
toast("Profile picture updated.");
} catch (error) {
updateAvatarSaveState(Boolean(state.avatarEditor.image));
toast(error.message || "Profile picture could not be saved.", "error");
}
}
Expand All @@ -1914,6 +1955,7 @@ async function loadAvatarEditor() {
if ((!supportedType && !supportedExtension) || file.size > 10 * 1024 * 1024) {
toast("Choose a PNG, JPEG, or WebP image up to 10 MiB.", "error");
byId("avatar-file").value = "";
updateAvatarSaveState(false);
return;
}
let image;
Expand Down Expand Up @@ -1960,6 +2002,7 @@ async function loadAvatarEditor() {
state.avatarEditor.y = (256 - height * base) / 2;
byId("avatar-editor").hidden = false;
drawAvatarEditor();
updateAvatarSaveState(true);
}

function drawAvatarEditor() {
Expand Down Expand Up @@ -2675,6 +2718,18 @@ async function resourceAction(action, id) {
state.user = response.user;
applyAvatar("profile-avatar", state.user);
applyAvatar("account-avatar", state.user);
byId("avatar-file").value = "";
byId("avatar-editor").hidden = true;
byId("avatar-zoom").value = "1";
if (state.avatarEditor.image && typeof state.avatarEditor.image.close === "function") {
state.avatarEditor.image.close();
}
state.avatarEditor.image = null;
const avatarSave = byId("avatar-save");
if (avatarSave) {
avatarSave.hidden = true;
avatarSave.disabled = true;
}
toast("Profile picture removed.");
return;
} else if (action === "export-platform") {
Expand Down Expand Up @@ -2908,6 +2963,7 @@ function bindEvents() {
byId("preview-form").addEventListener("submit", runPreview);
byId("password-form").addEventListener("submit", changePassword);
byId("preferences-form").addEventListener("submit", savePreferences);
byId("preference-language").addEventListener("change", applyLanguageDefaultTimezone);
byId("integration-settings-form").addEventListener("submit", saveIntegrationSettings);
byId("integration-settings-dialog").addEventListener("cancel", (event) => {
event.preventDefault();
Expand All @@ -2919,7 +2975,10 @@ function bindEvents() {
byId("backup-target-type").addEventListener("change", updateBackupTargetFields);
byId("restart-form").addEventListener("submit", restartPlatform);
byId("avatar-form").addEventListener("submit", saveAvatar);
byId("avatar-file").addEventListener("change", () => loadAvatarEditor().catch((error) => toast(error.message, "error")));
byId("avatar-file").addEventListener("change", () => loadAvatarEditor().catch((error) => {
updateAvatarSaveState(false);
toast(error.message, "error");
}));
byId("avatar-zoom").addEventListener("input", zoomAvatarEditor);
byId("avatar-canvas").addEventListener("pointerdown", (event) => {
state.avatarEditor.dragging = true;
Expand Down
89 changes: 89 additions & 0 deletions src/webui/i18n.js

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions src/webui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<script src="/ui/app.js" defer></script>
<script src="/ui/enhancements.js" defer></script>
<script src="/ui/qa_patch.js" defer></script>
<script src="/ui/i18n.js" defer></script>
<script src="/ui/dashboard.js" defer></script>
</head>
<body>
Expand Down Expand Up @@ -271,8 +272,8 @@ <h1 id="page-title">Dashboard</h1>
<article class="panel settings-card">
<div class="panel-heading"><div><p class="eyebrow">Regional settings</p><h3>Language, timezone, and clock</h3><p>Timezone also controls timestamps generated by Nowlert notification formatters.</p></div></div>
<form id="preferences-form" class="form-grid">
<label><span>Language</span><select id="preference-language"><option value="en-GB">English (United Kingdom)</option><option value="en-US">English (United States)</option><option value="pt-PT">Portuguese (Portugal)</option><option value="pt-BR">Portuguese (Brazil)</option></select></label>
<label><span>Timezone</span><input id="preference-timezone" list="timezone-suggestions" required placeholder="Europe/Lisbon"><datalist id="timezone-suggestions"><option value="Europe/Lisbon"><option value="Europe/London"><option value="Europe/Madrid"><option value="Europe/Paris"><option value="Europe/Berlin"><option value="Europe/Amsterdam"><option value="Europe/Rome"><option value="Europe/Zurich"><option value="Europe/Stockholm"><option value="UTC"><option value="America/New_York"><option value="America/Chicago"><option value="America/Denver"><option value="America/Los_Angeles"><option value="America/Toronto"><option value="America/Sao_Paulo"><option value="America/Mexico_City"><option value="Asia/Dubai"><option value="Asia/Kolkata"><option value="Asia/Singapore"><option value="Asia/Tokyo"><option value="Asia/Seoul"><option value="Australia/Sydney"><option value="Pacific/Auckland"><option value="Africa/Johannesburg"></datalist></label>
<label><span>Language</span><select id="preference-language"><option value="en-GB">English</option><option value="en-US" hidden>English</option><option value="pt-PT">Português</option><option value="pt-BR" hidden>Português</option><option value="es-ES">Español</option><option value="fr-FR">Français</option><option value="de-DE">Deutsch</option><option value="it-IT">Italiano</option><option value="nl-NL">Nederlands</option><option value="pl-PL">Polski</option><option value="cs-CZ">Čeština</option><option value="ro-RO">Română</option><option value="sv-SE">Svenska</option><option value="da-DK">Dansk</option><option value="nb-NO">Norsk</option><option value="fi-FI">Suomi</option><option value="el-GR">Ελληνικά</option><option value="tr-TR">Türkçe</option><option value="ru-RU">Русский</option><option value="uk-UA">Українська</option><option value="ja-JP">日本語</option><option value="zh-CN">简体中文</option></select></label>
<label><span>Timezone</span><select id="preference-timezone" required><option value="Europe/London">Europe/London</option><option value="America/New_York">America/New_York</option><option value="Europe/Lisbon">Europe/Lisbon</option><option value="America/Sao_Paulo">America/Sao_Paulo</option><option value="Europe/Madrid">Europe/Madrid</option><option value="Europe/Paris">Europe/Paris</option><option value="Europe/Berlin">Europe/Berlin</option><option value="Europe/Rome">Europe/Rome</option><option value="Europe/Amsterdam">Europe/Amsterdam</option><option value="Europe/Warsaw">Europe/Warsaw</option><option value="Europe/Prague">Europe/Prague</option><option value="Europe/Bucharest">Europe/Bucharest</option><option value="Europe/Stockholm">Europe/Stockholm</option><option value="Europe/Copenhagen">Europe/Copenhagen</option><option value="Europe/Oslo">Europe/Oslo</option><option value="Europe/Helsinki">Europe/Helsinki</option><option value="Europe/Athens">Europe/Athens</option><option value="Europe/Istanbul">Europe/Istanbul</option><option value="Europe/Moscow">Europe/Moscow</option><option value="Europe/Kyiv">Europe/Kyiv</option><option value="Asia/Tokyo">Asia/Tokyo</option><option value="Asia/Shanghai">Asia/Shanghai</option><option value="UTC">UTC</option><option value="America/Chicago">America/Chicago</option><option value="America/Denver">America/Denver</option><option value="America/Los_Angeles">America/Los_Angeles</option><option value="America/Toronto">America/Toronto</option><option value="America/Mexico_City">America/Mexico_City</option><option value="Asia/Dubai">Asia/Dubai</option><option value="Asia/Kolkata">Asia/Kolkata</option><option value="Asia/Singapore">Asia/Singapore</option><option value="Asia/Seoul">Asia/Seoul</option><option value="Australia/Sydney">Australia/Sydney</option><option value="Pacific/Auckland">Pacific/Auckland</option><option value="Africa/Johannesburg">Africa/Johannesburg</option></select></label>
<label><span>Time format</span><select id="preference-time-format"><option value="24">24-hour</option><option value="12">12-hour (AM/PM)</option></select></label>
<div class="wide"><button class="button primary" type="submit">Save settings</button></div>
</form>
Expand Down
88 changes: 45 additions & 43 deletions src/webui/qa_patch.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,28 @@ const QA_PAGE_SIZE = 25;
let qaRoutePage = 1;
let qaDeliveryPage = 1;
let qaAuditPage = 1;
const QA_AUDIT_PAGE_SIZE_KEY = "nowlert.audit.pageSize";
const QA_AUDIT_PAGE_SIZES = [25, 50, 100, 150, 250, 500];

function qaReadAuditPageSize() {
let stored = 25;
try {
stored = Number(window.localStorage.getItem(QA_AUDIT_PAGE_SIZE_KEY));
} catch (error) {
stored = 25;
}
return QA_AUDIT_PAGE_SIZES.includes(stored) ? stored : 25;
}

function qaWriteAuditPageSize(value) {
try {
window.localStorage.setItem(QA_AUDIT_PAGE_SIZE_KEY, String(value));
} catch (error) {
/* storage unavailable; selection stays in-session only */
}
}

let qaAuditPageSize = qaReadAuditPageSize();
let qaDeliveryPagination = { page: 1, page_size: QA_PAGE_SIZE, total: 0, total_pages: 1 };
let qaAuditPagination = { page: 1, page_size: QA_PAGE_SIZE, total: 0, total_pages: 1 };

Expand Down Expand Up @@ -85,8 +107,11 @@ async function qaLoadDeliveryPage(page) {

async function qaLoadAuditPage(page) {
try {
const response = await request(`/audit-events/page/${Math.max(1, Number(page || 1))}`);
const response = await request(
`/audit-events/page/${Math.max(1, Number(page || 1))}/size/${qaAuditPageSize}`,
);
state.audit = response.audit_events || [];
if (typeof state.auditPageSize === "number") state.auditPageSize = qaAuditPageSize;
qaAuditPagination = response.pagination || qaAuditPagination;
qaAuditPage = qaAuditPagination.page || 1;
qaOriginalRenderAudit();
Expand Down Expand Up @@ -114,6 +139,25 @@ loadWorkspace = async function loadWorkspaceWithPagination() {
await Promise.all([qaLoadDeliveryPage(qaDeliveryPage), qaLoadAuditPage(qaAuditPage)]);
};

function qaBindAuditPageSize() {
const select = byId("audit-page-size");
if (!select || select.dataset.qaPageSize === "1") return;
select.dataset.qaPageSize = "1";
qaAuditPageSize = qaReadAuditPageSize();
select.value = String(qaAuditPageSize);
if (typeof state.auditPageSize === "number") state.auditPageSize = qaAuditPageSize;
select.addEventListener("change", () => {
const chosen = Number(select.value);
qaAuditPageSize = QA_AUDIT_PAGE_SIZES.includes(chosen) ? chosen : 25;
qaWriteAuditPageSize(qaAuditPageSize);
if (typeof state.auditPageSize === "number") state.auditPageSize = qaAuditPageSize;
qaAuditPage = 1;
qaLoadAuditPage(1);
});
}

document.addEventListener("DOMContentLoaded", qaBindAuditPageSize);

function qaAddSelectActions(selectId) {
const select = byId(selectId);
if (!select || select.dataset.qaActions === "1") return;
Expand Down Expand Up @@ -143,53 +187,11 @@ function qaAddCounter(inputId, maximum) {
update();
}

function qaUpdateAvatarSave() {
const save = byId("avatar-save");
const file = byId("avatar-file");
if (!save || !file) return;
const selected = Boolean(file.files && file.files.length);
save.hidden = !selected;
save.disabled = !selected;
}

function qaEnhanceRegionalSettings() {
const language = byId("preference-language");
if (language) {
const labels = {
"en-US": "English (United States)",
"pt-BR": "Portuguese (Brazil)",
};
for (const [value, label] of Object.entries(labels)) {
if (![...language.options].some((option) => option.value === value)) {
language.append(element("option", { value, text: label }));
}
}
}
const zones = byId("timezone-suggestions");
if (zones) {
for (const value of [
"Europe/Berlin", "Europe/Amsterdam", "Europe/Rome", "Europe/Zurich", "Europe/Stockholm",
"America/Chicago", "America/Denver", "America/Toronto", "America/Sao_Paulo", "America/Mexico_City",
"Asia/Dubai", "Asia/Kolkata", "Asia/Singapore", "Asia/Tokyo", "Asia/Seoul",
"Australia/Sydney", "Pacific/Auckland", "Africa/Johannesburg",
]) {
if (![...zones.options].some((option) => option.value === value)) zones.append(element("option", { value }));
}
}
}

document.addEventListener("DOMContentLoaded", () => {
qaAddCounter("preview-event-title", 256);
qaAddCounter("preview-message", 4000);
qaAddCounter("user-name", 64);
for (const id of ["route-severities", "route-statuses", "route-exclude_severities", "route-exclude_statuses"]) qaAddSelectActions(id);
qaEnhanceRegionalSettings();
qaUpdateAvatarSave();
byId("avatar-file")?.addEventListener("change", qaUpdateAvatarSave);
byId("avatar-form")?.addEventListener("submit", () => window.setTimeout(qaUpdateAvatarSave, 0));
document.addEventListener("click", (event) => {
if (event.target.closest('[data-action="remove-avatar"]')) window.setTimeout(qaUpdateAvatarSave, 0);
});
});

/* Nowlert 3.1.0 empty-state icon restoration */
Expand Down
5 changes: 5 additions & 0 deletions src/webui/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ def __init__(
"text/javascript; charset=utf-8",
"no-cache",
),
"/ui/i18n.js": (
"src/webui/i18n.js",
"text/javascript; charset=utf-8",
"no-cache",
),
"/ui/styles.css": (
"src/webui/styles.css",
"text/css; charset=utf-8",
Expand Down
Loading