f85b648d97
* feat: notify user when a kandev update is detected Adds a background-poller-driven update notification with a saved preference for delivery channel (desktop / in-app banner / both) and an enable/disable switch on the Updates settings page. Backend: - internal/system/updates: NotifySettings + NotifyStore persist the enable/channel preference on the existing install-wide settings table; notifyIfNewUpdate broadcasts a WS event the first time a newer release is observed, deduped durably via kandev_meta so a restart or repeat poll doesn't re-announce the same version. - New GET/PUT /api/v1/system/updates/notification-settings endpoints. - pkg/websocket: new system.update_available action. Desktop (Tauri): - native_notifications.rs: task_id is now optional (Option<String>, required only for session-scoped events) and the event allowlist accepts system.update_available: alongside the existing session events, so the update notification can use the native bridge. Frontend: - New UpdateNotificationsCard on the Updates settings page (Switch + channel Select), wired through the shared settings-save-provider floating Save/Discard action. - useUpdateAvailableToast bridges the WS event to an in-app toast and/or the native/browser desktop notification, per the saved channel, deduped per version. * docs: document the update notification channel setting * fix: hydrate update notification settings and broadcast delivery ack - Only mark a release notification as delivered when the broadcaster reports at least one connected client, closing a race where an update could be silently dropped before the first client connects. - Hydrate updateNotificationSettings through the settings boot payload (loadSettingsInitialState -> hydrateState) instead of a component- level fetch, per the boot-payload -> store -> component data flow. Widen the hydration types (HydrationState) so callers can hydrate a partial system slice without fabricating the other system fields. UpdateNotificationsCard now only reads/writes the subscribed store state. - Fix module-scope settings-fetch cache leaking across tests in use-update-available-toast.test.ts so the fetch-failure test actually exercises the catch/fallback path. - Add API-client contract tests for fetchUpdateNotificationSettings and saveUpdateNotificationSettings (GET cache policy, PUT method and body). * test: cover delayed hydration and boot-payload mapping for update notifications
330 lines
10 KiB
TypeScript
330 lines
10 KiB
TypeScript
import { fetchJson, type ApiRequestOptions } from "../client";
|
|
import { getBackendConfig } from "@/lib/config";
|
|
import type {
|
|
SystemInfo,
|
|
DiskUsageResponse,
|
|
DatabaseStats,
|
|
SnapshotInfo,
|
|
LogFileInfo,
|
|
LogTailResponse,
|
|
UpdatesResponse,
|
|
UpdateNotificationSettings,
|
|
JobAcceptResponse,
|
|
RestartCapability,
|
|
RestartResponse,
|
|
StorageAdoptionResponse,
|
|
StorageMaintenanceRun,
|
|
StorageMaintenanceSettings,
|
|
StorageOverviewResponse,
|
|
StorageQuarantineEntry,
|
|
StorageSettingsResponse,
|
|
} from "@/lib/types/system";
|
|
|
|
const SYSTEM_BASE = "/api/v1/system";
|
|
|
|
// --- Info ---------------------------------------------------------------
|
|
|
|
export function fetchSystemInfo(options?: ApiRequestOptions): Promise<SystemInfo> {
|
|
return fetchJson<SystemInfo>(`${SYSTEM_BASE}/info`, options);
|
|
}
|
|
|
|
// --- Disk usage ---------------------------------------------------------
|
|
|
|
export function fetchDiskUsage(options?: ApiRequestOptions): Promise<DiskUsageResponse> {
|
|
return fetchJson<DiskUsageResponse>(`${SYSTEM_BASE}/disk-usage`, options);
|
|
}
|
|
|
|
export function refreshDiskUsage(options?: ApiRequestOptions): Promise<JobAcceptResponse> {
|
|
return fetchJson<JobAcceptResponse>(`${SYSTEM_BASE}/disk-usage/refresh`, {
|
|
...options,
|
|
init: { method: "POST", ...(options?.init ?? {}) },
|
|
});
|
|
}
|
|
|
|
export function openDataFolder(options?: ApiRequestOptions): Promise<{ path: string }> {
|
|
return fetchJson<{ path: string }>(`${SYSTEM_BASE}/disk-usage/open`, {
|
|
...options,
|
|
init: { method: "POST", ...(options?.init ?? {}) },
|
|
});
|
|
}
|
|
|
|
// --- Database -----------------------------------------------------------
|
|
|
|
export function fetchDatabaseStats(options?: ApiRequestOptions): Promise<DatabaseStats> {
|
|
return fetchJson<DatabaseStats>(`${SYSTEM_BASE}/database`, options);
|
|
}
|
|
|
|
export function vacuumDatabase(options?: ApiRequestOptions): Promise<JobAcceptResponse> {
|
|
return fetchJson<JobAcceptResponse>(`${SYSTEM_BASE}/database/vacuum`, {
|
|
...options,
|
|
init: { method: "POST", ...(options?.init ?? {}) },
|
|
});
|
|
}
|
|
|
|
export function optimizeDatabase(options?: ApiRequestOptions): Promise<JobAcceptResponse> {
|
|
return fetchJson<JobAcceptResponse>(`${SYSTEM_BASE}/database/optimize`, {
|
|
...options,
|
|
init: { method: "POST", ...(options?.init ?? {}) },
|
|
});
|
|
}
|
|
|
|
export function resetDatabase(
|
|
confirm: string,
|
|
options?: ApiRequestOptions,
|
|
): Promise<JobAcceptResponse> {
|
|
return fetchJson<JobAcceptResponse>(`${SYSTEM_BASE}/database/reset`, {
|
|
...options,
|
|
init: { method: "POST", body: JSON.stringify({ confirm }), ...(options?.init ?? {}) },
|
|
});
|
|
}
|
|
|
|
// --- Backups ------------------------------------------------------------
|
|
|
|
export async function fetchBackups(options?: ApiRequestOptions): Promise<SnapshotInfo[]> {
|
|
const res = await fetchJson<{ snapshots: SnapshotInfo[] }>(`${SYSTEM_BASE}/backups`, options);
|
|
return res.snapshots ?? [];
|
|
}
|
|
|
|
export function createBackup(options?: ApiRequestOptions): Promise<JobAcceptResponse> {
|
|
return fetchJson<JobAcceptResponse>(`${SYSTEM_BASE}/backups`, {
|
|
...options,
|
|
init: { method: "POST", ...(options?.init ?? {}) },
|
|
});
|
|
}
|
|
|
|
export function restoreBackup(
|
|
name: string,
|
|
confirm: string,
|
|
options?: ApiRequestOptions,
|
|
): Promise<JobAcceptResponse> {
|
|
return fetchJson<JobAcceptResponse>(
|
|
`${SYSTEM_BASE}/backups/${encodeURIComponent(name)}/restore`,
|
|
{
|
|
...options,
|
|
init: { method: "POST", body: JSON.stringify({ confirm }), ...(options?.init ?? {}) },
|
|
},
|
|
);
|
|
}
|
|
|
|
export function deleteBackup(name: string, options?: ApiRequestOptions): Promise<void> {
|
|
return fetchJson<void>(`${SYSTEM_BASE}/backups/${encodeURIComponent(name)}`, {
|
|
...options,
|
|
init: { method: "DELETE", ...(options?.init ?? {}) },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Builds an absolute URL for downloading a snapshot. Snapshot downloads stream
|
|
* binary data, so they cannot reuse fetchJson — they are typically wired into
|
|
* <a href={...} download> or window.open().
|
|
*/
|
|
export function buildBackupDownloadUrl(name: string, baseUrl?: string): string {
|
|
const root = baseUrl ?? getBackendConfig().apiBaseUrl;
|
|
return `${root}${SYSTEM_BASE}/backups/${encodeURIComponent(name)}/download`;
|
|
}
|
|
|
|
// --- Logs ---------------------------------------------------------------
|
|
|
|
export async function fetchLogFiles(options?: ApiRequestOptions): Promise<LogFileInfo[]> {
|
|
const res = await fetchJson<{ files: LogFileInfo[] }>(`${SYSTEM_BASE}/logs`, options);
|
|
return res.files ?? [];
|
|
}
|
|
|
|
export function fetchLogTail(n = 1000, options?: ApiRequestOptions): Promise<LogTailResponse> {
|
|
return fetchJson<LogTailResponse>(`${SYSTEM_BASE}/logs/tail?n=${encodeURIComponent(String(n))}`, {
|
|
cache: "no-store",
|
|
...options,
|
|
});
|
|
}
|
|
|
|
export function buildLogDownloadUrl(name: string, baseUrl?: string): string {
|
|
const root = baseUrl ?? getBackendConfig().apiBaseUrl;
|
|
return `${root}${SYSTEM_BASE}/logs/${encodeURIComponent(name)}/download`;
|
|
}
|
|
|
|
// --- Jobs ---------------------------------------------------------------
|
|
|
|
export function fetchSystemJob(
|
|
jobId: string,
|
|
options?: ApiRequestOptions,
|
|
): Promise<import("@/lib/types/system").SystemJob> {
|
|
return fetchJson<import("@/lib/types/system").SystemJob>(
|
|
`${SYSTEM_BASE}/jobs/${encodeURIComponent(jobId)}`,
|
|
{ cache: "no-store", ...options },
|
|
);
|
|
}
|
|
|
|
// --- Updates ------------------------------------------------------------
|
|
|
|
export function fetchUpdates(options?: ApiRequestOptions): Promise<UpdatesResponse> {
|
|
return fetchJson<UpdatesResponse>(`${SYSTEM_BASE}/updates`, options);
|
|
}
|
|
|
|
export function checkUpdates(options?: ApiRequestOptions): Promise<UpdatesResponse> {
|
|
return fetchJson<UpdatesResponse>(`${SYSTEM_BASE}/updates/check`, {
|
|
...options,
|
|
init: { method: "POST", ...(options?.init ?? {}) },
|
|
});
|
|
}
|
|
|
|
export function applyUpdate(
|
|
confirm = "UPDATE",
|
|
options?: ApiRequestOptions,
|
|
): Promise<JobAcceptResponse> {
|
|
return fetchJson<JobAcceptResponse>(`${SYSTEM_BASE}/updates/apply`, {
|
|
...options,
|
|
// Spread caller init first so the required method/body can't be overridden.
|
|
init: { ...(options?.init ?? {}), method: "POST", body: JSON.stringify({ confirm }) },
|
|
});
|
|
}
|
|
|
|
export function fetchUpdateNotificationSettings(
|
|
options?: ApiRequestOptions,
|
|
): Promise<UpdateNotificationSettings> {
|
|
return fetchJson<UpdateNotificationSettings>(`${SYSTEM_BASE}/updates/notification-settings`, {
|
|
...options,
|
|
cache: "no-store",
|
|
});
|
|
}
|
|
|
|
export function saveUpdateNotificationSettings(
|
|
settings: UpdateNotificationSettings,
|
|
options?: ApiRequestOptions,
|
|
): Promise<UpdateNotificationSettings> {
|
|
return fetchJson<UpdateNotificationSettings>(`${SYSTEM_BASE}/updates/notification-settings`, {
|
|
...options,
|
|
init: { ...(options?.init ?? {}), method: "PUT", body: JSON.stringify(settings) },
|
|
});
|
|
}
|
|
|
|
// --- Restart ------------------------------------------------------------
|
|
|
|
export function fetchRestartCapability(options?: ApiRequestOptions): Promise<RestartCapability> {
|
|
return fetchJson<RestartCapability>(`${SYSTEM_BASE}/restart-capability`, {
|
|
...options,
|
|
cache: "no-store",
|
|
});
|
|
}
|
|
|
|
export function requestRestart(options?: ApiRequestOptions): Promise<RestartResponse> {
|
|
return fetchJson<RestartResponse>(`${SYSTEM_BASE}/restart`, {
|
|
...options,
|
|
init: { ...(options?.init ?? {}), method: "POST" },
|
|
});
|
|
}
|
|
|
|
// --- Storage maintenance ------------------------------------------------
|
|
|
|
export function fetchStorageOverview(
|
|
options?: ApiRequestOptions,
|
|
): Promise<StorageOverviewResponse> {
|
|
return fetchJson<StorageOverviewResponse>(`${SYSTEM_BASE}/storage`, {
|
|
...options,
|
|
cache: "no-store",
|
|
});
|
|
}
|
|
|
|
export function saveStorageSettings(
|
|
settings: StorageMaintenanceSettings,
|
|
dedicatedDockerConfirmation?: "DEDICATED",
|
|
options?: ApiRequestOptions,
|
|
): Promise<StorageSettingsResponse> {
|
|
return fetchJson<StorageSettingsResponse>(`${SYSTEM_BASE}/storage/settings`, {
|
|
...options,
|
|
init: {
|
|
...(options?.init ?? {}),
|
|
method: "PATCH",
|
|
body: JSON.stringify({
|
|
settings,
|
|
confirmations: dedicatedDockerConfirmation
|
|
? { dedicated_docker: dedicatedDockerConfirmation }
|
|
: {},
|
|
}),
|
|
},
|
|
});
|
|
}
|
|
|
|
export function adoptStorageGoCache(
|
|
path: string,
|
|
options?: ApiRequestOptions,
|
|
): Promise<StorageAdoptionResponse> {
|
|
return fetchJson<StorageAdoptionResponse>(`${SYSTEM_BASE}/storage/go-cache/adopt`, {
|
|
...options,
|
|
init: {
|
|
...(options?.init ?? {}),
|
|
method: "POST",
|
|
body: JSON.stringify({ path, confirm: "ADOPT" }),
|
|
},
|
|
});
|
|
}
|
|
|
|
export function analyzeStorage(options?: ApiRequestOptions): Promise<JobAcceptResponse> {
|
|
return fetchJson<JobAcceptResponse>(`${SYSTEM_BASE}/storage/analyze`, {
|
|
...options,
|
|
init: { ...(options?.init ?? {}), method: "POST" },
|
|
});
|
|
}
|
|
|
|
export function runStorageMaintenance(
|
|
resources?: string[],
|
|
options?: ApiRequestOptions,
|
|
): Promise<JobAcceptResponse> {
|
|
return fetchJson<JobAcceptResponse>(`${SYSTEM_BASE}/storage/run`, {
|
|
...options,
|
|
init: {
|
|
...(options?.init ?? {}),
|
|
method: "POST",
|
|
body: JSON.stringify(resources?.length ? { resources } : {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function fetchStorageRuns(
|
|
limit = 20,
|
|
options?: ApiRequestOptions,
|
|
): Promise<StorageMaintenanceRun[]> {
|
|
const response = await fetchJson<{ runs: StorageMaintenanceRun[] }>(
|
|
`${SYSTEM_BASE}/storage/runs?limit=${encodeURIComponent(String(limit))}`,
|
|
{ ...options, cache: "no-store" },
|
|
);
|
|
return response.runs ?? [];
|
|
}
|
|
|
|
export async function fetchStorageQuarantine(
|
|
options?: ApiRequestOptions,
|
|
): Promise<StorageQuarantineEntry[]> {
|
|
const response = await fetchJson<{ entries: StorageQuarantineEntry[] }>(
|
|
`${SYSTEM_BASE}/storage/quarantine`,
|
|
{ ...options, cache: "no-store" },
|
|
);
|
|
return response.entries ?? [];
|
|
}
|
|
|
|
export async function restoreStorageQuarantine(
|
|
id: string,
|
|
options?: ApiRequestOptions,
|
|
): Promise<StorageQuarantineEntry> {
|
|
const response = await fetchJson<{ entry: StorageQuarantineEntry }>(
|
|
`${SYSTEM_BASE}/storage/quarantine/${encodeURIComponent(id)}/restore`,
|
|
{ ...options, init: { ...(options?.init ?? {}), method: "POST" } },
|
|
);
|
|
return response.entry;
|
|
}
|
|
|
|
export function deleteStorageQuarantine(
|
|
id: string,
|
|
options?: ApiRequestOptions,
|
|
): Promise<JobAcceptResponse> {
|
|
return fetchJson<JobAcceptResponse>(
|
|
`${SYSTEM_BASE}/storage/quarantine/${encodeURIComponent(id)}`,
|
|
{
|
|
...options,
|
|
init: {
|
|
...(options?.init ?? {}),
|
|
method: "DELETE",
|
|
body: JSON.stringify({ confirm: "DELETE" }),
|
|
},
|
|
},
|
|
);
|
|
}
|