Phase 6: remote backup destinations — SFTP & S3 (0.6.0)
- BackupDestination model + backup_destination_service (SFTP via paramiko,
S3-compatible via boto3): upload/list/download/delete/test.
- routers/destinations.py: destinations CRUD (secrets masked, merge-on-update),
test, list/delete remote backups. backups.py: POST /{id}/backup/push and
POST /restore-from (download from a destination + restore, volumes included).
- Frontend: Settings → Backup destinations (SFTP/S3 forms + test); Backup dialog
can push to a destination; Restore dialog can pick a destination + backup.
- deps: paramiko 3.5.0, boto3 1.35.99.
Verified end-to-end against live MinIO + atmoz/sftp: create/test destinations,
push (incl. volumes), list, restore-from to a fresh stack (volume data intact),
delete remote backup.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
59037f4287
commit
7bd449101d
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "stackpilot-frontend",
|
||||
"private": true,
|
||||
"version": "0.5.0",
|
||||
"version": "0.6.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
import api from "./client";
|
||||
|
||||
export interface BackupDestination {
|
||||
id: number;
|
||||
name: string;
|
||||
type: "sftp" | "s3";
|
||||
config: Record<string, string>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface RemoteBackup {
|
||||
name: string;
|
||||
size: number;
|
||||
modified: number | null;
|
||||
}
|
||||
|
||||
function triggerDownload(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
@@ -42,4 +56,50 @@ export const backupsApi = {
|
||||
}>("/api/stacks/restore", form);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
push: (
|
||||
stackId: string,
|
||||
body: { destination_id: number; include_volumes: boolean; stop_first: boolean }
|
||||
) =>
|
||||
api
|
||||
.post<{ ok: boolean; destination: string; name: string }>(
|
||||
`/api/stacks/${stackId}/backup/push`,
|
||||
body
|
||||
)
|
||||
.then((r) => r.data),
|
||||
|
||||
restoreFrom: (body: {
|
||||
destination_id: number;
|
||||
name: string;
|
||||
target_id?: string;
|
||||
overwrite: boolean;
|
||||
restore_volumes: boolean;
|
||||
}) =>
|
||||
api
|
||||
.post<{ stack_id: string; name: string; volumes_restored: number }>(
|
||||
"/api/stacks/restore-from",
|
||||
body
|
||||
)
|
||||
.then((r) => r.data),
|
||||
};
|
||||
|
||||
export const destinationsApi = {
|
||||
list: () =>
|
||||
api.get<BackupDestination[]>("/api/backups/destinations").then((r) => r.data),
|
||||
create: (body: { name: string; type: string; config: Record<string, string> }) =>
|
||||
api.post<BackupDestination>("/api/backups/destinations", body).then((r) => r.data),
|
||||
update: (id: number, body: { name?: string; config?: Record<string, string> }) =>
|
||||
api.put<BackupDestination>(`/api/backups/destinations/${id}`, body).then((r) => r.data),
|
||||
remove: (id: number) =>
|
||||
api.delete(`/api/backups/destinations/${id}`).then((r) => r.data),
|
||||
test: (id: number) =>
|
||||
api
|
||||
.post<{ ok: boolean; error?: string }>(`/api/backups/destinations/${id}/test`)
|
||||
.then((r) => r.data),
|
||||
backups: (id: number) =>
|
||||
api.get<RemoteBackup[]>(`/api/backups/destinations/${id}/backups`).then((r) => r.data),
|
||||
deleteBackup: (id: number, name: string) =>
|
||||
api
|
||||
.delete(`/api/backups/destinations/${id}/backups/${encodeURIComponent(name)}`)
|
||||
.then((r) => r.data),
|
||||
};
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useState } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Archive, Upload } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui";
|
||||
import { backupsApi } from "@/api/backups";
|
||||
import { backupsApi, destinationsApi } from "@/api/backups";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
import { formatBytes } from "@/lib/utils";
|
||||
|
||||
const selectClass =
|
||||
"w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800";
|
||||
|
||||
function Checkbox({
|
||||
checked,
|
||||
@@ -50,14 +54,30 @@ export function BackupButton({ stackId }: { stackId: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [includeVolumes, setIncludeVolumes] = useState(true);
|
||||
const [stopFirst, setStopFirst] = useState(true);
|
||||
const [target, setTarget] = useState("download"); // "download" | destination id
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const destinations = useQuery({
|
||||
queryKey: ["destinations"],
|
||||
queryFn: destinationsApi.list,
|
||||
enabled: open,
|
||||
});
|
||||
|
||||
const run = async () => {
|
||||
setBusy(true);
|
||||
const tid = toast.loading("Creating backup…");
|
||||
try {
|
||||
await backupsApi.download(stackId, { includeVolumes, stopFirst });
|
||||
toast.success("Backup downloaded", { id: tid });
|
||||
if (target === "download") {
|
||||
await backupsApi.download(stackId, { includeVolumes, stopFirst });
|
||||
toast.success("Backup downloaded", { id: tid });
|
||||
} else {
|
||||
const res = await backupsApi.push(stackId, {
|
||||
destination_id: Number(target),
|
||||
include_volumes: includeVolumes,
|
||||
stop_first: stopFirst,
|
||||
});
|
||||
toast.success(`Backup pushed to ${res.destination}`, { id: tid });
|
||||
}
|
||||
setOpen(false);
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e), { id: tid });
|
||||
@@ -75,6 +95,17 @@ export function BackupButton({ stackId }: { stackId: string }) {
|
||||
<Modal onClose={() => !busy && setOpen(false)}>
|
||||
<h2 className="mb-3 text-lg font-semibold">Back up “{stackId}”</h2>
|
||||
<div className="space-y-3">
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs font-medium text-slate-500">Destination</span>
|
||||
<select className={selectClass} value={target} onChange={(e) => setTarget(e.target.value)}>
|
||||
<option value="download">Download to browser</option>
|
||||
{destinations.data?.map((d) => (
|
||||
<option key={d.id} value={String(d.id)}>
|
||||
{d.name} ({d.type})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<Checkbox
|
||||
checked={includeVolumes}
|
||||
onChange={setIncludeVolumes}
|
||||
@@ -93,7 +124,7 @@ export function BackupButton({ stackId }: { stackId: string }) {
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={run} loading={busy}>
|
||||
Download backup
|
||||
{target === "download" ? "Download backup" : "Push backup"}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -105,33 +136,62 @@ export function BackupButton({ stackId }: { stackId: string }) {
|
||||
export function RestoreButton() {
|
||||
const qc = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [mode, setMode] = useState<"upload" | "destination">("upload");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [destId, setDestId] = useState<string>("");
|
||||
const [remoteName, setRemoteName] = useState<string>("");
|
||||
const [targetId, setTargetId] = useState("");
|
||||
const [overwrite, setOverwrite] = useState(false);
|
||||
const [restoreVolumes, setRestoreVolumes] = useState(true);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const destinations = useQuery({
|
||||
queryKey: ["destinations"],
|
||||
queryFn: destinationsApi.list,
|
||||
enabled: open,
|
||||
});
|
||||
const remoteBackups = useQuery({
|
||||
queryKey: ["dest-backups", destId],
|
||||
queryFn: () => destinationsApi.backups(Number(destId)),
|
||||
enabled: open && mode === "destination" && !!destId,
|
||||
});
|
||||
|
||||
const run = async () => {
|
||||
if (!file) {
|
||||
toast.error("Select a backup file");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
const tid = toast.loading("Restoring…");
|
||||
try {
|
||||
const res = await backupsApi.restore(file, {
|
||||
targetId: targetId.trim() || undefined,
|
||||
overwrite,
|
||||
restoreVolumes,
|
||||
});
|
||||
toast.success(
|
||||
`Restored '${res.stack_id}' (${res.volumes_restored} volume(s))`,
|
||||
{ id: tid }
|
||||
);
|
||||
let res;
|
||||
if (mode === "upload") {
|
||||
if (!file) {
|
||||
toast.error("Select a backup file", { id: tid });
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
res = await backupsApi.restore(file, {
|
||||
targetId: targetId.trim() || undefined,
|
||||
overwrite,
|
||||
restoreVolumes,
|
||||
});
|
||||
} else {
|
||||
if (!destId || !remoteName) {
|
||||
toast.error("Pick a destination and a backup", { id: tid });
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
res = await backupsApi.restoreFrom({
|
||||
destination_id: Number(destId),
|
||||
name: remoteName,
|
||||
target_id: targetId.trim() || undefined,
|
||||
overwrite,
|
||||
restore_volumes: restoreVolumes,
|
||||
});
|
||||
}
|
||||
toast.success(`Restored '${res.stack_id}' (${res.volumes_restored} volume(s))`, { id: tid });
|
||||
qc.invalidateQueries({ queryKey: ["stacks"] });
|
||||
setOpen(false);
|
||||
setFile(null);
|
||||
setTargetId("");
|
||||
setRemoteName("");
|
||||
} catch (e) {
|
||||
toast.error(apiErrorMessage(e), { id: tid });
|
||||
} finally {
|
||||
@@ -147,13 +207,73 @@ export function RestoreButton() {
|
||||
{open && (
|
||||
<Modal onClose={() => !busy && setOpen(false)}>
|
||||
<h2 className="mb-3 text-lg font-semibold">Restore from backup</h2>
|
||||
|
||||
<div className="mb-3 flex gap-1 rounded-lg bg-slate-100 p-1 text-sm dark:bg-slate-800">
|
||||
{(["upload", "destination"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
className={
|
||||
mode === m
|
||||
? "flex-1 rounded-md bg-card px-3 py-1.5 font-medium shadow-sm dark:bg-card-dark"
|
||||
: "flex-1 rounded-md px-3 py-1.5 text-slate-500"
|
||||
}
|
||||
>
|
||||
{m === "upload" ? "Upload file" : "From destination"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="file"
|
||||
accept=".tar.gz,.tgz,application/gzip"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
className="block w-full text-sm text-slate-600 file:mr-3 file:rounded-lg file:border-0 file:bg-accent file:px-3 file:py-2 file:text-sm file:text-white dark:text-slate-300 dark:file:bg-accent-dark dark:file:text-slate-900"
|
||||
/>
|
||||
{mode === "upload" ? (
|
||||
<input
|
||||
type="file"
|
||||
accept=".tar.gz,.tgz,application/gzip"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
className="block w-full text-sm text-slate-600 file:mr-3 file:rounded-lg file:border-0 file:bg-accent file:px-3 file:py-2 file:text-sm file:text-white dark:text-slate-300 dark:file:bg-accent-dark dark:file:text-slate-900"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs font-medium text-slate-500">Destination</span>
|
||||
<select
|
||||
className={selectClass}
|
||||
value={destId}
|
||||
onChange={(e) => {
|
||||
setDestId(e.target.value);
|
||||
setRemoteName("");
|
||||
}}
|
||||
>
|
||||
<option value="">Select…</option>
|
||||
{destinations.data?.map((d) => (
|
||||
<option key={d.id} value={String(d.id)}>
|
||||
{d.name} ({d.type})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{destId && (
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs font-medium text-slate-500">
|
||||
Backup {remoteBackups.isFetching && "(loading…)"}
|
||||
</span>
|
||||
<select
|
||||
className={selectClass}
|
||||
value={remoteName}
|
||||
onChange={(e) => setRemoteName(e.target.value)}
|
||||
>
|
||||
<option value="">Select…</option>
|
||||
{remoteBackups.data?.map((b) => (
|
||||
<option key={b.name} value={b.name}>
|
||||
{b.name} — {formatBytes(b.size)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<label className="block space-y-1">
|
||||
<span className="text-xs font-medium text-slate-500">
|
||||
Restore as (optional — leave blank to use the original name)
|
||||
@@ -162,14 +282,10 @@ export function RestoreButton() {
|
||||
value={targetId}
|
||||
onChange={(e) => setTargetId(e.target.value)}
|
||||
placeholder="new-stack-name"
|
||||
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800"
|
||||
className={selectClass}
|
||||
/>
|
||||
</label>
|
||||
<Checkbox
|
||||
checked={restoreVolumes}
|
||||
onChange={setRestoreVolumes}
|
||||
label="Restore volume data"
|
||||
/>
|
||||
<Checkbox checked={restoreVolumes} onChange={setRestoreVolumes} label="Restore volume data" />
|
||||
<Checkbox
|
||||
checked={overwrite}
|
||||
onChange={setOverwrite}
|
||||
@@ -181,7 +297,11 @@ export function RestoreButton() {
|
||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={run} loading={busy} disabled={!file}>
|
||||
<Button
|
||||
onClick={run}
|
||||
loading={busy}
|
||||
disabled={mode === "upload" ? !file : !destId || !remoteName}
|
||||
>
|
||||
Restore
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Power,
|
||||
Server,
|
||||
RefreshCw,
|
||||
HardDrive,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge, Button, Card, Input, Spinner } from "@/components/ui";
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
type Webhook,
|
||||
type WebhookInput,
|
||||
} from "@/api/settings";
|
||||
import { destinationsApi, type BackupDestination } from "@/api/backups";
|
||||
import { agentsApi } from "@/api/agents";
|
||||
import { HostDot } from "@/components/hosts/HostDot";
|
||||
import { apiErrorMessage } from "@/api/client";
|
||||
@@ -45,12 +47,171 @@ export function Settings() {
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
<GeneralSection />
|
||||
<HostsSection />
|
||||
<DestinationsSection />
|
||||
<NotificationsSection />
|
||||
<UsersSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Backup destinations */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
const selectClass =
|
||||
"w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm dark:border-slate-600 dark:bg-slate-800";
|
||||
|
||||
const FIELDS: Record<string, { key: string; label: string; secret?: boolean; area?: boolean; placeholder?: string }[]> = {
|
||||
sftp: [
|
||||
{ key: "host", label: "Host" },
|
||||
{ key: "port", label: "Port", placeholder: "22" },
|
||||
{ key: "username", label: "Username" },
|
||||
{ key: "password", label: "Password", secret: true },
|
||||
{ key: "private_key", label: "Private key (optional, instead of password)", secret: true, area: true },
|
||||
{ key: "path", label: "Remote directory", placeholder: "/backups/stackpilot" },
|
||||
],
|
||||
s3: [
|
||||
{ key: "endpoint_url", label: "Endpoint URL (blank = AWS)", placeholder: "https://minio.example:9000" },
|
||||
{ key: "region", label: "Region", placeholder: "us-east-1" },
|
||||
{ key: "bucket", label: "Bucket" },
|
||||
{ key: "access_key", label: "Access key", secret: true },
|
||||
{ key: "secret_key", label: "Secret key", secret: true },
|
||||
{ key: "prefix", label: "Key prefix (optional)", placeholder: "stackpilot/" },
|
||||
],
|
||||
};
|
||||
|
||||
function DestinationsSection() {
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading } = useQuery({ queryKey: ["destinations"], queryFn: destinationsApi.list });
|
||||
const [adding, setAdding] = useState(false);
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: ["destinations"] });
|
||||
|
||||
return (
|
||||
<section>
|
||||
<SectionTitle icon={<HardDrive className="h-4 w-4" />}>Backup destinations</SectionTitle>
|
||||
<div className="space-y-3">
|
||||
{isLoading ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
data?.map((d) => <DestinationRow key={d.id} dest={d} onChange={invalidate} />)
|
||||
)}
|
||||
{data?.length === 0 && !adding && (
|
||||
<Card>
|
||||
<p className="text-sm text-slate-500">
|
||||
No destinations. Add an SFTP server or S3-compatible bucket to push stack
|
||||
backups off-box and restore from them.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
{adding ? (
|
||||
<DestinationForm onDone={() => { setAdding(false); invalidate(); }} onCancel={() => setAdding(false)} />
|
||||
) : (
|
||||
<Button variant="outline" onClick={() => setAdding(true)}>
|
||||
<Plus className="h-4 w-4" /> Add destination
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DestinationRow({ dest, onChange }: { dest: BackupDestination; onChange: () => void }) {
|
||||
const test = useMutation({
|
||||
mutationFn: () => destinationsApi.test(dest.id),
|
||||
onSuccess: (r) =>
|
||||
r.ok ? toast.success("Reachable") : toast.error(r.error || "Connection failed"),
|
||||
onError: (e) => toast.error(apiErrorMessage(e)),
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: () => destinationsApi.remove(dest.id),
|
||||
onSuccess: () => { toast.success("Destination removed"); onChange(); },
|
||||
onError: (e) => toast.error(apiErrorMessage(e)),
|
||||
});
|
||||
|
||||
const summary =
|
||||
dest.type === "s3"
|
||||
? `${dest.config.bucket}${dest.config.prefix ? "/" + dest.config.prefix : ""}`
|
||||
: `${dest.config.username}@${dest.config.host}:${dest.config.path || "."}`;
|
||||
|
||||
return (
|
||||
<Card className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{dest.name}</span>
|
||||
<Badge>{dest.type}</Badge>
|
||||
</div>
|
||||
<p className="break-all font-mono text-xs text-slate-500">{summary}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={() => test.mutate()} loading={test.isPending}>
|
||||
<RefreshCw className="h-4 w-4" /> Test
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => remove.mutate()} loading={remove.isPending}>
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DestinationForm({ onDone, onCancel }: { onDone: () => void; onCancel: () => void }) {
|
||||
const [name, setName] = useState("");
|
||||
const [type, setType] = useState("sftp");
|
||||
const [config, setConfig] = useState<Record<string, string>>({});
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () => destinationsApi.create({ name, type, config }),
|
||||
onSuccess: () => { toast.success("Destination added"); onDone(); },
|
||||
onError: (e) => toast.error(apiErrorMessage(e)),
|
||||
});
|
||||
|
||||
const setField = (k: string, v: string) => setConfig((c) => ({ ...c, [k]: v }));
|
||||
|
||||
return (
|
||||
<Card className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs font-medium text-slate-500">Name</span>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="offsite-nas" />
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs font-medium text-slate-500">Type</span>
|
||||
<select className={selectClass} value={type} onChange={(e) => { setType(e.target.value); setConfig({}); }}>
|
||||
<option value="sftp">SFTP</option>
|
||||
<option value="s3">S3-compatible</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{FIELDS[type].map((f) => (
|
||||
<label key={f.key} className="block space-y-1">
|
||||
<span className="text-xs font-medium text-slate-500">{f.label}</span>
|
||||
{f.area ? (
|
||||
<textarea
|
||||
value={config[f.key] ?? ""}
|
||||
onChange={(e) => setField(f.key, e.target.value)}
|
||||
rows={3}
|
||||
className={selectClass + " font-mono"}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type={f.secret ? "password" : "text"}
|
||||
placeholder={f.placeholder}
|
||||
value={config[f.key] ?? ""}
|
||||
onChange={(e) => setField(f.key, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={onCancel}>Cancel</Button>
|
||||
<Button onClick={() => create.mutate()} loading={create.isPending} disabled={!name.trim()}>
|
||||
Add destination
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Remote hosts (agents) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
Reference in New Issue
Block a user