mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 10:49:35 +00:00
feat(settings): scope split — developer settings always target local node
- Add localOnly option to apiFetch — omits x-node-id header so the request bypasses the proxy and always hits the local Sencho instance - fetchSettings now performs two fetches when a remote node is active: active node fetch for per-node settings (CPU/RAM/disk limits, janitor, crash detection), and a localOnly fetch for UI preferences (developer_mode, global_logs_refresh, metrics_retention_hours, log_retention_days) - saveDeveloperSettings passes localOnly: true — developer preferences can no longer be written into a remote node's database - Update scope badges: System Limits shows "Configuring: [node]", Developer shows "Always Local" when a remote node is active
This commit is contained in:
@@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Added:** `PATCH /api/settings` bulk-update endpoint — accepts a partial settings object, validates all values via a Zod schema (type checking, range enforcement, URL format validation), and persists changes atomically in a single SQLite transaction. Replaces the N+1 per-key POST loop.
|
||||
- **Added:** `system_state` SQLite table — separates runtime operational state (e.g. janitor alert cooldown timestamp) from user-defined configuration in `global_settings`. `MonitorService` now writes `last_janitor_alert_timestamp` to `system_state` instead of `global_settings`, eliminating false positives in future audit logging.
|
||||
- **Added:** `metrics_retention_hours` (default: 24h) and `log_retention_days` (default: 30d) configurable settings — `MonitorService` now reads these dynamically each cycle instead of using hardcoded values. Notification history is pruned on the same cycle as container metrics.
|
||||
- **Fixed:** Developer settings (`developer_mode`, `global_logs_refresh`, `metrics_retention_hours`, `log_retention_days`) are now correctly scoped to the local Sencho instance — when a remote node is active, `fetchSettings` performs a secondary `localOnly` fetch for these keys and `saveDeveloperSettings` always writes to local via the new `apiFetch({ localOnly: true })` option, preventing UI preferences from being proxied into remote nodes' databases.
|
||||
- **Added:** `localOnly` option on `apiFetch` — omits the `x-node-id` header so requests always route to the local node regardless of the active node context.
|
||||
- **UI:** System Limits badge on remote nodes now reads "Configuring: [node name]"; Developer tab badge reads "Always Local" to make scope explicit to the user.
|
||||
- **Refactor:** `SettingsModal` frontend overhauled — per-operation loading states replace the single shared `isLoading` flag (saving system settings no longer disables notification test buttons). Settings are fetched before UI is interactive (skeleton loader blocks premature saves). Only known patchable keys are hydrated into component state (auth keys can never enter React state). Unsaved-changes dot indicator on sidebar nav items. All saves use the new `PATCH /api/settings` endpoint. Developer tab gains a "Data Retention" section for metrics and log retention controls.
|
||||
- **Added:** App Store category filter — LSIO templates are now grouped into categories (Automation, Downloaders, Media, Monitoring, Networking, Security, Development, Productivity, Utilities, Other) via a static lookup map in `TemplateService`. A horizontal pill bar below the search field lets users filter by category; clicking a category badge on a template card also activates the filter. Category badges highlight when their category is the active filter. App count updates reactively.
|
||||
- **Added:** App Store registry settings — new "App Store" section in Settings lets users supply a custom Portainer v2 JSON template URL to override the default LinuxServer.io registry. "Save & Refresh" persists the URL and immediately busts the 24-hour template cache via `POST /api/templates/refresh-cache`. Portainer v2 registries pass their native `categories` field through unchanged.
|
||||
|
||||
@@ -137,25 +137,33 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
||||
const fetchSettings = async () => {
|
||||
setIsSettingsLoading(true);
|
||||
try {
|
||||
const res = await apiFetch('/settings');
|
||||
if (res.ok) {
|
||||
const data: Record<string, string> = await res.json();
|
||||
// Explicitly pick only known patchable keys — never allow auth keys into component state
|
||||
const safe: PatchableSettings = {
|
||||
host_cpu_limit: data.host_cpu_limit ?? DEFAULT_SETTINGS.host_cpu_limit,
|
||||
host_ram_limit: data.host_ram_limit ?? DEFAULT_SETTINGS.host_ram_limit,
|
||||
host_disk_limit: data.host_disk_limit ?? DEFAULT_SETTINGS.host_disk_limit,
|
||||
docker_janitor_gb: data.docker_janitor_gb ?? DEFAULT_SETTINGS.docker_janitor_gb,
|
||||
global_crash: (data.global_crash as '0' | '1') ?? DEFAULT_SETTINGS.global_crash,
|
||||
global_logs_refresh: (data.global_logs_refresh as '1' | '3' | '5' | '10') ?? DEFAULT_SETTINGS.global_logs_refresh,
|
||||
developer_mode: (data.developer_mode as '0' | '1') ?? DEFAULT_SETTINGS.developer_mode,
|
||||
template_registry_url: data.template_registry_url ?? '',
|
||||
metrics_retention_hours: data.metrics_retention_hours ?? DEFAULT_SETTINGS.metrics_retention_hours,
|
||||
log_retention_days: data.log_retention_days ?? DEFAULT_SETTINGS.log_retention_days,
|
||||
};
|
||||
setSettings(safe);
|
||||
serverSettingsRef.current = { ...safe };
|
||||
}
|
||||
// Fetch per-node settings from the active node (system limits etc.)
|
||||
const nodeRes = await apiFetch('/settings');
|
||||
// Always fetch developer/UI preferences from local — these control
|
||||
// this Sencho instance's behaviour and must never be proxied to remote
|
||||
const localRes = isRemote ? await apiFetch('/settings', { localOnly: true }) : nodeRes;
|
||||
|
||||
const nodeData: Record<string, string> = nodeRes.ok ? await nodeRes.json() : {};
|
||||
const localData: Record<string, string> = (isRemote && localRes.ok)
|
||||
? await localRes.json()
|
||||
: nodeData;
|
||||
|
||||
const safe: PatchableSettings = {
|
||||
// Per-node: read from active node
|
||||
host_cpu_limit: nodeData.host_cpu_limit ?? DEFAULT_SETTINGS.host_cpu_limit,
|
||||
host_ram_limit: nodeData.host_ram_limit ?? DEFAULT_SETTINGS.host_ram_limit,
|
||||
host_disk_limit: nodeData.host_disk_limit ?? DEFAULT_SETTINGS.host_disk_limit,
|
||||
docker_janitor_gb: nodeData.docker_janitor_gb ?? DEFAULT_SETTINGS.docker_janitor_gb,
|
||||
global_crash: (nodeData.global_crash as '0' | '1') ?? DEFAULT_SETTINGS.global_crash,
|
||||
template_registry_url: nodeData.template_registry_url ?? '',
|
||||
// Local-only: always read from local node
|
||||
global_logs_refresh: (localData.global_logs_refresh as '1' | '3' | '5' | '10') ?? DEFAULT_SETTINGS.global_logs_refresh,
|
||||
developer_mode: (localData.developer_mode as '0' | '1') ?? DEFAULT_SETTINGS.developer_mode,
|
||||
metrics_retention_hours: localData.metrics_retention_hours ?? DEFAULT_SETTINGS.metrics_retention_hours,
|
||||
log_retention_days: localData.log_retention_days ?? DEFAULT_SETTINGS.log_retention_days,
|
||||
};
|
||||
setSettings(safe);
|
||||
serverSettingsRef.current = { ...safe };
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch settings', e);
|
||||
} finally {
|
||||
@@ -167,12 +175,13 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
||||
setSettings(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const patchSettings = async (payload: PatchableSettings, setLoading: (v: boolean) => void): Promise<boolean> => {
|
||||
const patchSettings = async (payload: PatchableSettings, setLoading: (v: boolean) => void, localOnly = false): Promise<boolean> => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch('/settings', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
localOnly,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
@@ -201,12 +210,13 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
||||
};
|
||||
|
||||
const saveDeveloperSettings = async () => {
|
||||
// Developer/UI preferences are local-only — never proxy to remote node
|
||||
const ok = await patchSettings({
|
||||
developer_mode: settings.developer_mode,
|
||||
global_logs_refresh: settings.global_logs_refresh,
|
||||
metrics_retention_hours: settings.metrics_retention_hours,
|
||||
log_retention_days: settings.log_retention_days,
|
||||
}, setIsSavingDeveloper);
|
||||
}, setIsSavingDeveloper, true);
|
||||
if (ok) toast.success('Developer settings saved.');
|
||||
};
|
||||
|
||||
@@ -468,7 +478,7 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
||||
{isRemote && (
|
||||
<Badge variant="outline" className="text-xs shrink-0 ml-2 mt-0.5">
|
||||
<Info className="w-3 h-3 mr-1" />
|
||||
{activeNode!.name}
|
||||
Configuring: {activeNode!.name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -608,9 +618,9 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
||||
<p className="text-sm text-muted-foreground">Power user settings for real-time observability and data retention.</p>
|
||||
</div>
|
||||
{isRemote && (
|
||||
<Badge variant="outline" className="text-xs shrink-0 ml-2 mt-0.5">
|
||||
<Badge variant="secondary" className="text-xs shrink-0 ml-2 mt-0.5">
|
||||
<Info className="w-3 h-3 mr-1" />
|
||||
{activeNode!.name}
|
||||
Always Local
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+12
-5
@@ -1,22 +1,29 @@
|
||||
const API_BASE = '/api';
|
||||
|
||||
export interface ApiFetchOptions extends RequestInit {
|
||||
/** When true, omits the x-node-id header so the request always targets
|
||||
* the local node regardless of which node is currently active in the UI. */
|
||||
localOnly?: boolean;
|
||||
}
|
||||
|
||||
export async function apiFetch(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
options: ApiFetchOptions = {}
|
||||
): Promise<Response> {
|
||||
const { localOnly, ...fetchOptions } = options;
|
||||
const url = `${API_BASE}${endpoint}`;
|
||||
const activeNodeId = localStorage.getItem('sencho-active-node');
|
||||
|
||||
const activeNodeId = localOnly ? null : localStorage.getItem('sencho-active-node');
|
||||
|
||||
const defaultOptions: RequestInit = {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(activeNodeId ? { 'x-node-id': activeNodeId } : {}),
|
||||
...options.headers,
|
||||
...fetchOptions.headers,
|
||||
},
|
||||
};
|
||||
|
||||
const response = await fetch(url, { ...defaultOptions, ...options });
|
||||
const response = await fetch(url, { ...defaultOptions, ...fetchOptions });
|
||||
|
||||
if (response.status === 401) {
|
||||
// Signal auth failure to AuthContext without a hard page reload
|
||||
|
||||
Reference in New Issue
Block a user