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:
co-authored by
Claude Opus 4.8
parent
22d9864436
commit
8d19b09abd
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
Reference in New Issue
Block a user