Phase 4: backups w/ volumes, notifications, settings & users, audit page (0.4.0)

- Backup/restore: per-stack tar.gz incl. named-volume snapshots (helper
  container), upload restore with rename/overwrite/conflict detection.
- Notifications: ntfy/Discord/Slack/Gotify/generic webhooks, per-event
  subscriptions; wired into the update checker and stack lifecycle.
- Settings page: update-check interval, webhook CRUD + test, user management
  (with last-admin safeguards).
- Audit log page (searchable, paginated).
- Mobile-responsive sidebar/layout.

Multi-host agents and remote backup destinations (SFTP/S3) deferred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-07 20:58:05 +00:00
co-authored by Claude Opus 4.8
parent 22d9864436
commit 8d19b09abd
30 changed files with 2034 additions and 71 deletions
+45
View File
@@ -0,0 +1,45 @@
import api from "./client";
function triggerDownload(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
export const backupsApi = {
download: async (
stackId: string,
opts: { includeVolumes: boolean; stopFirst: boolean }
) => {
const res = await api.get(`/api/stacks/${stackId}/backup`, {
params: { include_volumes: opts.includeVolumes, stop_first: opts.stopFirst },
responseType: "blob",
});
const cd = res.headers["content-disposition"] as string | undefined;
const match = cd?.match(/filename="?([^"]+)"?/);
const name = match?.[1] ?? `backup-${stackId}.tar.gz`;
triggerDownload(res.data as Blob, name);
},
restore: async (
file: File,
opts: { targetId?: string; overwrite: boolean; restoreVolumes: boolean }
) => {
const form = new FormData();
form.append("file", file);
if (opts.targetId) form.append("target_id", opts.targetId);
form.append("overwrite", String(opts.overwrite));
form.append("restore_volumes", String(opts.restoreVolumes));
const res = await api.post<{
stack_id: string;
name: string;
volumes_restored: number;
}>("/api/stacks/restore", form);
return res.data;
},
};
+56
View File
@@ -0,0 +1,56 @@
import api from "./client";
import type { User } from "@/types";
export interface AppSettings {
update_check_interval_minutes: number;
env_webhook_count: number;
available_events: string[];
webhook_types: string[];
}
export interface Webhook {
id: number;
name: string;
url: string;
type: string;
events: string[];
enabled: boolean;
created_at: string;
}
export interface WebhookInput {
name: string;
url: string;
type: string;
events: string[];
enabled: boolean;
}
export const settingsApi = {
get: () => api.get<AppSettings>("/api/settings").then((r) => r.data),
update: (body: { update_check_interval_minutes?: number }) =>
api.put<AppSettings>("/api/settings", body).then((r) => r.data),
listWebhooks: () =>
api.get<Webhook[]>("/api/settings/webhooks").then((r) => r.data),
createWebhook: (body: WebhookInput) =>
api.post<Webhook>("/api/settings/webhooks", body).then((r) => r.data),
updateWebhook: (id: number, body: Partial<WebhookInput>) =>
api.put<Webhook>(`/api/settings/webhooks/${id}`, body).then((r) => r.data),
deleteWebhook: (id: number) =>
api.delete(`/api/settings/webhooks/${id}`).then((r) => r.data),
testWebhook: (id: number) =>
api.post<{ ok: boolean }>(`/api/settings/webhooks/${id}/test`).then((r) => r.data),
};
export const usersApi = {
list: () => api.get<User[]>("/api/auth/users").then((r) => r.data),
create: (body: { username: string; password: string; role: string }) =>
api.post<User>("/api/auth/users", body).then((r) => r.data),
update: (
id: number,
body: { password?: string; role?: string; is_active?: boolean }
) => api.patch<User>(`/api/auth/users/${id}`, body).then((r) => r.data),
remove: (id: number) =>
api.delete(`/api/auth/users/${id}`).then((r) => r.data),
};