refactor(ui): split backup console panels
This commit is contained in:
parent
b74c5b6ca3
commit
1f56b415ed
@ -1,4 +1,3 @@
|
||||
# relative_path max_lines reason
|
||||
internal/k8s/jobs.go 670 legacy-oversize
|
||||
internal/server/server.go 2203 legacy-oversize
|
||||
web/src/App.tsx 918 legacy-oversize
|
||||
|
||||
|
759
web/src/App.tsx
759
web/src/App.tsx
@ -1,286 +1,62 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { B2UsagePanel } from './components/B2UsagePanel';
|
||||
import { BackupPoliciesPanel } from './components/BackupPoliciesPanel';
|
||||
import { PVCInventoryPanel } from './components/PVCInventoryPanel';
|
||||
import { RestorePlannerPanel } from './components/RestorePlannerPanel';
|
||||
import {
|
||||
type AuthInfo,
|
||||
type BackupListResponse,
|
||||
type BackupPolicy,
|
||||
type BackupPolicyListResponse,
|
||||
type B2UsageResponse,
|
||||
EMPTY_B2,
|
||||
type InventoryResponse,
|
||||
type RestoreSelection
|
||||
} from './soteria-types';
|
||||
import {
|
||||
delay,
|
||||
fetchJSON,
|
||||
suggestNamespacePrefix,
|
||||
suggestTargetPVCName
|
||||
} from './soteria-ui-helpers';
|
||||
import './styles.css';
|
||||
|
||||
interface AuthInfo {
|
||||
authenticated: boolean;
|
||||
user?: string;
|
||||
email?: string;
|
||||
groups?: string[];
|
||||
allowed_groups?: string[];
|
||||
}
|
||||
|
||||
interface BackupRecord {
|
||||
name: string;
|
||||
snapshot_name?: string;
|
||||
created?: string;
|
||||
state?: string;
|
||||
url?: string;
|
||||
size?: string;
|
||||
latest?: boolean;
|
||||
}
|
||||
|
||||
interface BackupListResponse {
|
||||
namespace: string;
|
||||
pvc: string;
|
||||
volume: string;
|
||||
backups: BackupRecord[];
|
||||
}
|
||||
|
||||
interface PVCInventory {
|
||||
namespace: string;
|
||||
pvc: string;
|
||||
volume?: string;
|
||||
phase?: string;
|
||||
storage_class?: string;
|
||||
capacity?: string;
|
||||
access_modes?: string[];
|
||||
driver?: string;
|
||||
last_backup_at?: string;
|
||||
last_backup_age_hours?: number;
|
||||
backup_count: number;
|
||||
completed_backups: number;
|
||||
active_backups: number;
|
||||
last_job_name?: string;
|
||||
last_job_state?: string;
|
||||
last_job_started_at?: string;
|
||||
last_job_progress_pct: number;
|
||||
last_backup_size_bytes?: number;
|
||||
total_backup_size_bytes?: number;
|
||||
healthy: boolean;
|
||||
health_reason?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface NamespaceInventory {
|
||||
name: string;
|
||||
pvcs: PVCInventory[];
|
||||
}
|
||||
|
||||
interface InventoryResponse {
|
||||
generated_at: string;
|
||||
namespaces: NamespaceInventory[];
|
||||
}
|
||||
|
||||
interface BackupPolicy {
|
||||
id: string;
|
||||
namespace: string;
|
||||
pvc?: string;
|
||||
interval_hours: number;
|
||||
enabled: boolean;
|
||||
dedupe?: boolean;
|
||||
keep_last?: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
interface BackupPolicyListResponse {
|
||||
policies: BackupPolicy[];
|
||||
}
|
||||
|
||||
interface B2BucketUsage {
|
||||
name: string;
|
||||
object_count: number;
|
||||
total_bytes: number;
|
||||
recent_objects_24h: number;
|
||||
recent_bytes_24h: number;
|
||||
last_modified_at?: string;
|
||||
}
|
||||
|
||||
interface B2UsageResponse {
|
||||
enabled: boolean;
|
||||
available: boolean;
|
||||
endpoint?: string;
|
||||
region?: string;
|
||||
scanned_at?: string;
|
||||
scan_duration_ms?: number;
|
||||
total_objects: number;
|
||||
total_bytes: number;
|
||||
recent_objects_24h: number;
|
||||
recent_bytes_24h: number;
|
||||
buckets?: B2BucketUsage[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
type RestoreSelection =
|
||||
| { kind: 'none' }
|
||||
| { kind: 'pvc'; namespace: string; pvc: string; volume: string; backups: BackupRecord[] }
|
||||
| { kind: 'namespace'; namespace: string };
|
||||
|
||||
const EMPTY_B2: B2UsageResponse = {
|
||||
enabled: false,
|
||||
available: false,
|
||||
total_objects: 0,
|
||||
total_bytes: 0,
|
||||
recent_objects_24h: 0,
|
||||
recent_bytes_24h: 0,
|
||||
buckets: []
|
||||
};
|
||||
|
||||
function looksLikeHTML(value: string): boolean {
|
||||
const sample = value.trim().slice(0, 512).toLowerCase();
|
||||
return sample.startsWith('<!doctype html') || sample.includes('<html');
|
||||
}
|
||||
|
||||
function extractHTMLTitle(value: string): string {
|
||||
const match = value.match(/<title>\s*([^<]+)\s*<\/title>/i);
|
||||
return match?.[1]?.trim() || '';
|
||||
}
|
||||
|
||||
function extractRequestID(value: string): string {
|
||||
const match = value.match(/Request ID:\s*([0-9a-f-]+)/i);
|
||||
return match?.[1]?.trim() || '';
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
window.setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchJSON<T>(input: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(input, init);
|
||||
const text = await response.text();
|
||||
let payload: unknown = {};
|
||||
if (text.trim() !== '') {
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
payload = { error: text };
|
||||
}
|
||||
}
|
||||
if (!response.ok) {
|
||||
let message = typeof payload === 'object' && payload !== null && 'error' in payload
|
||||
? String((payload as { error: unknown }).error)
|
||||
: `${response.status} ${response.statusText}`;
|
||||
if (looksLikeHTML(text)) {
|
||||
const title = extractHTMLTitle(text);
|
||||
const requestID = extractRequestID(text);
|
||||
message = `upstream gateway error (${response.status}${title ? ` ${title}` : ''})`;
|
||||
if (requestID) {
|
||||
message = `${message}; request id ${requestID}`;
|
||||
}
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
function formatBytes(value?: number): string {
|
||||
if (value === undefined || value === null || Number.isNaN(value)) {
|
||||
return 'n/a';
|
||||
}
|
||||
if (value <= 0) {
|
||||
return '0 B';
|
||||
}
|
||||
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
|
||||
let size = value;
|
||||
let unit = 0;
|
||||
while (size >= 1024 && unit < units.length - 1) {
|
||||
size /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${size.toFixed(unit === 0 ? 0 : 2)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
function formatTimestamp(value?: string): string {
|
||||
if (!value) {
|
||||
return 'n/a';
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.valueOf())) {
|
||||
return value;
|
||||
}
|
||||
return parsed.toLocaleString();
|
||||
}
|
||||
|
||||
function formatLabel(value?: string): string {
|
||||
if (!value) {
|
||||
return 'Needs attention';
|
||||
}
|
||||
return value.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
function progressChipClass(state?: string): 'good' | 'warn' | 'bad' {
|
||||
switch ((state || '').toLowerCase()) {
|
||||
case 'completed':
|
||||
return 'good';
|
||||
case 'failed':
|
||||
return 'bad';
|
||||
default:
|
||||
return 'warn';
|
||||
}
|
||||
}
|
||||
|
||||
function suggestTargetPVCName(sourcePVC: string): string {
|
||||
const now = new Date();
|
||||
const pad = (item: number) => String(item).padStart(2, '0');
|
||||
const stamp = [
|
||||
now.getUTCFullYear(),
|
||||
pad(now.getUTCMonth() + 1),
|
||||
pad(now.getUTCDate()),
|
||||
pad(now.getUTCHours()),
|
||||
pad(now.getUTCMinutes())
|
||||
].join('');
|
||||
return (`restore-${sourcePVC}-${stamp}`)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 63)
|
||||
.replace(/-+$/g, '');
|
||||
}
|
||||
|
||||
function suggestNamespacePrefix(): string {
|
||||
const now = new Date();
|
||||
const pad = (item: number) => String(item).padStart(2, '0');
|
||||
const stamp = [
|
||||
now.getUTCFullYear(),
|
||||
pad(now.getUTCMonth() + 1),
|
||||
pad(now.getUTCDate()),
|
||||
pad(now.getUTCHours()),
|
||||
pad(now.getUTCMinutes())
|
||||
].join('');
|
||||
return `restore-${stamp}-`;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [auth, setAuth] = useState<AuthInfo | null>(null);
|
||||
const [authError, setAuthError] = useState<string>('');
|
||||
|
||||
const [authError, setAuthError] = useState('');
|
||||
const [inventory, setInventory] = useState<InventoryResponse | null>(null);
|
||||
const [inventoryError, setInventoryError] = useState<string>('');
|
||||
|
||||
const [inventoryError, setInventoryError] = useState('');
|
||||
const [policies, setPolicies] = useState<BackupPolicy[]>([]);
|
||||
const [policyError, setPolicyError] = useState<string>('');
|
||||
|
||||
const [policyError, setPolicyError] = useState('');
|
||||
const [b2Usage, setB2Usage] = useState<B2UsageResponse>(EMPTY_B2);
|
||||
const [b2Error, setB2Error] = useState<string>('');
|
||||
const [b2Refreshing, setB2Refreshing] = useState<boolean>(false);
|
||||
|
||||
const [b2Error, setB2Error] = useState('');
|
||||
const [b2Refreshing, setB2Refreshing] = useState(false);
|
||||
const [selection, setSelection] = useState<RestoreSelection>({ kind: 'none' });
|
||||
const [restoreNamespace, setRestoreNamespace] = useState<string>('');
|
||||
const [restorePVC, setRestorePVC] = useState<string>('');
|
||||
const [restoreBackupURL, setRestoreBackupURL] = useState<string>('');
|
||||
|
||||
const [namespaceRestoreTarget, setNamespaceRestoreTarget] = useState<string>('');
|
||||
const [namespaceRestorePrefix, setNamespaceRestorePrefix] = useState<string>(suggestNamespacePrefix());
|
||||
const [namespaceRestoreSnapshot, setNamespaceRestoreSnapshot] = useState<string>('');
|
||||
const [restoreNamespace, setRestoreNamespace] = useState('');
|
||||
const [restorePVC, setRestorePVC] = useState('');
|
||||
const [restoreBackupURL, setRestoreBackupURL] = useState('');
|
||||
const [namespaceRestoreTarget, setNamespaceRestoreTarget] = useState('');
|
||||
const [namespaceRestorePrefix, setNamespaceRestorePrefix] = useState(suggestNamespacePrefix());
|
||||
const [namespaceRestoreSnapshot, setNamespaceRestoreSnapshot] = useState('');
|
||||
|
||||
const [policyNamespace, setPolicyNamespace] = useState<string>('');
|
||||
const [policyPVC, setPolicyPVC] = useState<string>('');
|
||||
const [policyIntervalHours, setPolicyIntervalHours] = useState<number>(24);
|
||||
const [policyEnabled, setPolicyEnabled] = useState<boolean>(true);
|
||||
const [policyDedupe, setPolicyDedupe] = useState<boolean>(true);
|
||||
const [policyKeepLast, setPolicyKeepLast] = useState<number>(0);
|
||||
const [manualDedupe, setManualDedupe] = useState<boolean>(true);
|
||||
const [manualKeepLast, setManualKeepLast] = useState<number>(0);
|
||||
const [policyNamespace, setPolicyNamespace] = useState('');
|
||||
const [policyPVC, setPolicyPVC] = useState('');
|
||||
const [policyIntervalHours, setPolicyIntervalHours] = useState(24);
|
||||
const [policyEnabled, setPolicyEnabled] = useState(true);
|
||||
const [policyDedupe, setPolicyDedupe] = useState(true);
|
||||
const [policyKeepLast, setPolicyKeepLast] = useState(0);
|
||||
const [manualDedupe, setManualDedupe] = useState(true);
|
||||
const [manualKeepLast, setManualKeepLast] = useState(0);
|
||||
|
||||
const [lastAction, setLastAction] = useState('No action yet.');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const [lastAction, setLastAction] = useState<string>('No action yet.');
|
||||
const [busy, setBusy] = useState<boolean>(false);
|
||||
const activeBackupCount = useMemo(() => {
|
||||
if (!inventory) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
for (const namespace of inventory.namespaces) {
|
||||
for (const pvc of namespace.pvcs) {
|
||||
@ -290,12 +66,10 @@ function App() {
|
||||
return total;
|
||||
}, [inventory]);
|
||||
|
||||
const namespaceOptions = useMemo(() => {
|
||||
if (!inventory) {
|
||||
return [] as string[];
|
||||
}
|
||||
return inventory.namespaces.map((item) => item.name);
|
||||
}, [inventory]);
|
||||
const namespaceOptions = useMemo(
|
||||
() => inventory?.namespaces.map((item) => item.name) ?? [],
|
||||
[inventory]
|
||||
);
|
||||
|
||||
const writeAction = (payload: unknown): void => {
|
||||
if (typeof payload === 'string') {
|
||||
@ -317,7 +91,8 @@ function App() {
|
||||
};
|
||||
|
||||
const loadInventory = async (): Promise<void> => {
|
||||
const fetchInventory = async (): Promise<InventoryResponse> => fetchJSON<InventoryResponse>('/v1/inventory');
|
||||
const fetchInventory = async (): Promise<InventoryResponse> => fetchJSON('/v1/inventory');
|
||||
|
||||
try {
|
||||
const payload = await fetchInventory();
|
||||
setInventory(payload);
|
||||
@ -327,6 +102,8 @@ function App() {
|
||||
}
|
||||
} catch (error) {
|
||||
let message = error instanceof Error ? error.message : 'failed to load inventory';
|
||||
|
||||
// The API occasionally returns a transient gateway HTML page while upstream warms back up.
|
||||
if (message.includes('upstream gateway error')) {
|
||||
try {
|
||||
await delay(1000);
|
||||
@ -341,6 +118,7 @@ function App() {
|
||||
message = retryError instanceof Error ? retryError.message : message;
|
||||
}
|
||||
}
|
||||
|
||||
setInventoryError(message);
|
||||
}
|
||||
};
|
||||
@ -358,7 +136,8 @@ function App() {
|
||||
|
||||
const loadB2Usage = async (forceRefresh = false): Promise<void> => {
|
||||
try {
|
||||
const payload = await fetchJSON<B2UsageResponse>(forceRefresh ? '/v1/b2?refresh=1' : '/v1/b2');
|
||||
const endpoint = forceRefresh ? '/v1/b2?refresh=1' : '/v1/b2';
|
||||
const payload = await fetchJSON<B2UsageResponse>(endpoint);
|
||||
setB2Usage(payload);
|
||||
setB2Error('');
|
||||
} catch (error) {
|
||||
@ -393,9 +172,11 @@ function App() {
|
||||
if (activeBackupCount <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const handle = window.setInterval(() => {
|
||||
void loadInventory();
|
||||
}, 8000);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(handle);
|
||||
};
|
||||
@ -407,12 +188,22 @@ function App() {
|
||||
const payload = await fetchJSON<unknown>('/v1/backup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ namespace, pvc, dry_run: false, dedupe: manualDedupe, keep_last: manualKeepLast })
|
||||
body: JSON.stringify({
|
||||
namespace,
|
||||
pvc,
|
||||
dry_run: false,
|
||||
dedupe: manualDedupe,
|
||||
keep_last: manualKeepLast
|
||||
})
|
||||
});
|
||||
writeAction(payload);
|
||||
await Promise.all([loadInventory(), loadB2Usage()]);
|
||||
} catch (error) {
|
||||
writeAction({ error: error instanceof Error ? error.message : 'backup request failed', namespace, pvc });
|
||||
writeAction({
|
||||
error: error instanceof Error ? error.message : 'backup request failed',
|
||||
namespace,
|
||||
pvc
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@ -424,12 +215,20 @@ function App() {
|
||||
const payload = await fetchJSON<unknown>('/v1/backup/namespace', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ namespace, dry_run: false, dedupe: manualDedupe, keep_last: manualKeepLast })
|
||||
body: JSON.stringify({
|
||||
namespace,
|
||||
dry_run: false,
|
||||
dedupe: manualDedupe,
|
||||
keep_last: manualKeepLast
|
||||
})
|
||||
});
|
||||
writeAction(payload);
|
||||
await Promise.all([loadInventory(), loadB2Usage()]);
|
||||
} catch (error) {
|
||||
writeAction({ error: error instanceof Error ? error.message : 'namespace backup failed', namespace });
|
||||
writeAction({
|
||||
error: error instanceof Error ? error.message : 'namespace backup failed',
|
||||
namespace
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@ -438,7 +237,9 @@ function App() {
|
||||
const openPVCSelection = async (namespace: string, pvc: string): Promise<void> => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = await fetchJSON<BackupListResponse>(`/v1/backups?namespace=${encodeURIComponent(namespace)}&pvc=${encodeURIComponent(pvc)}`);
|
||||
const payload = await fetchJSON<BackupListResponse>(
|
||||
`/v1/backups?namespace=${encodeURIComponent(namespace)}&pvc=${encodeURIComponent(pvc)}`
|
||||
);
|
||||
const completed = payload.backups.filter((item) => item.state === 'Completed' && item.url);
|
||||
setSelection({ kind: 'pvc', namespace, pvc, volume: payload.volume, backups: completed });
|
||||
setRestoreNamespace(namespace);
|
||||
@ -446,7 +247,11 @@ function App() {
|
||||
setRestoreBackupURL(completed.length > 0 ? String(completed[0].url) : '');
|
||||
writeAction(payload);
|
||||
} catch (error) {
|
||||
writeAction({ error: error instanceof Error ? error.message : 'failed to load backups', namespace, pvc });
|
||||
writeAction({
|
||||
error: error instanceof Error ? error.message : 'failed to load backups',
|
||||
namespace,
|
||||
pvc
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@ -464,6 +269,7 @@ function App() {
|
||||
if (selection.kind !== 'pvc') {
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = await fetchJSON<unknown>('/v1/restores', {
|
||||
@ -498,6 +304,7 @@ function App() {
|
||||
if (selection.kind !== 'namespace') {
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = await fetchJSON<unknown>('/v1/restores/namespace', {
|
||||
@ -559,25 +366,40 @@ function App() {
|
||||
writeAction(payload);
|
||||
await Promise.all([loadPolicies(), loadInventory()]);
|
||||
} catch (error) {
|
||||
writeAction({ error: error instanceof Error ? error.message : 'policy delete failed', policy_id: policyID });
|
||||
writeAction({
|
||||
error: error instanceof Error ? error.message : 'policy delete failed',
|
||||
policy_id: policyID
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadPolicyIntoForm = (policy: BackupPolicy): void => {
|
||||
setPolicyNamespace(policy.namespace);
|
||||
setPolicyPVC(policy.pvc || '');
|
||||
setPolicyIntervalHours(policy.interval_hours);
|
||||
setPolicyEnabled(policy.enabled);
|
||||
setPolicyDedupe(policy.dedupe !== false);
|
||||
setPolicyKeepLast(policy.keep_last ?? 0);
|
||||
};
|
||||
|
||||
const authLabel = auth
|
||||
? `${auth.user || auth.email || 'authenticated'} | groups: ${(auth.groups || []).join(', ') || 'none'}`
|
||||
: authError || 'anonymous';
|
||||
const allowedGroupLabel = auth?.allowed_groups && auth.allowed_groups.length > 0
|
||||
? `Access requires: ${auth.allowed_groups.join(', ')}`
|
||||
: 'Access requires: any authenticated user';
|
||||
const allowedGroupLabel =
|
||||
auth?.allowed_groups && auth.allowed_groups.length > 0
|
||||
? `Access requires: ${auth.allowed_groups.join(', ')}`
|
||||
: 'Access requires: any authenticated user';
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="topbar">
|
||||
<div>
|
||||
<h1>Soteria Backup Console</h1>
|
||||
<p className="subtle">Dark-mode React UI for backup drills, policy control, and B2 consumption visibility.</p>
|
||||
<p className="subtle">
|
||||
Dark-mode React UI for backup drills, policy control, and B2 consumption visibility.
|
||||
</p>
|
||||
</div>
|
||||
<div className="toolbar">
|
||||
<span className={`chip ${auth ? 'good' : 'warn'}`}>{authLabel}</span>
|
||||
@ -594,237 +416,46 @@ function App() {
|
||||
</header>
|
||||
|
||||
<main className="layout">
|
||||
<section className="panel scroll-panel">
|
||||
<div className="panel-header">
|
||||
<h2>PVC Inventory</h2>
|
||||
<span className="subtle">{inventory?.generated_at ? `Updated ${formatTimestamp(inventory.generated_at)}` : 'No inventory yet'}</span>
|
||||
</div>
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" checked={manualDedupe} onChange={(event) => setManualDedupe(event.target.checked)} />
|
||||
Dedupe unchanged blocks (default)
|
||||
</label>
|
||||
<label>
|
||||
Keep last snapshots per PVC (0 = keep all)
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1000}
|
||||
value={manualKeepLast}
|
||||
onChange={(event) => setManualKeepLast(Math.max(0, Math.min(1000, Number(event.target.value || 0))))}
|
||||
/>
|
||||
</label>
|
||||
<p className="subtle tiny">This setting applies to both `Backup now` and `Backup namespace` actions.</p>
|
||||
{inventoryError && <p className="error">{inventoryError}</p>}
|
||||
{!inventory && !inventoryError && <p className="subtle">Loading inventory...</p>}
|
||||
{inventory?.namespaces.map((namespace) => (
|
||||
<article key={namespace.name} className="namespace-block">
|
||||
<div className="namespace-row">
|
||||
<h3>{namespace.name}</h3>
|
||||
<div className="actions">
|
||||
<button type="button" className="secondary" onClick={() => void triggerNamespaceBackup(namespace.name)} disabled={busy}>
|
||||
Backup namespace
|
||||
</button>
|
||||
<button type="button" className="secondary" onClick={() => openNamespaceSelection(namespace.name)}>
|
||||
Restore namespace
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pvc-grid">
|
||||
{namespace.pvcs.map((pvc) => {
|
||||
const healthClass = pvc.healthy ? 'good' : (pvc.health_reason === 'in_progress' ? 'warn' : 'bad');
|
||||
const healthLabel = pvc.healthy ? 'Healthy' : formatLabel(pvc.health_reason);
|
||||
const progressPct = Math.max(0, Math.min(100, Number(pvc.last_job_progress_pct || 0)));
|
||||
const progressClass = progressChipClass(pvc.last_job_state);
|
||||
const showProgress = Boolean(pvc.last_job_name) || (pvc.active_backups || 0) > 0;
|
||||
const latestSizeLabel = formatBytes(pvc.last_backup_size_bytes);
|
||||
const totalStoredLabel = formatBytes(pvc.total_backup_size_bytes);
|
||||
const showResticSizeHint = pvc.driver === 'restic'
|
||||
&& (pvc.last_backup_size_bytes === undefined || pvc.total_backup_size_bytes === undefined);
|
||||
|
||||
return (
|
||||
<article key={`${pvc.namespace}/${pvc.pvc}`} className="pvc-card">
|
||||
<div className="pvc-title-row">
|
||||
<div>
|
||||
<h4>{pvc.pvc}</h4>
|
||||
<p className="subtle tiny">{pvc.volume || 'unknown volume'} | {pvc.storage_class || 'no class'} | {pvc.capacity || 'unknown size'}</p>
|
||||
</div>
|
||||
<span className={`chip ${healthClass}`}>
|
||||
{healthLabel}
|
||||
</span>
|
||||
</div>
|
||||
<p className="subtle tiny">
|
||||
Last backup: {pvc.last_backup_at ? `${formatTimestamp(pvc.last_backup_at)} (${(pvc.last_backup_age_hours || 0).toFixed(1)}h ago)` : 'never'}
|
||||
</p>
|
||||
<p className="subtle tiny">
|
||||
Backups: {pvc.completed_backups}/{pvc.backup_count} completed | Latest size: {latestSizeLabel} | Total stored: {totalStoredLabel}
|
||||
</p>
|
||||
{showResticSizeHint && (
|
||||
<p className="subtle tiny">Per-PVC storage is estimated from restic upload summaries persisted by Soteria. Older backups created before tracking may show n/a until a new backup runs.</p>
|
||||
)}
|
||||
{showProgress && (
|
||||
<div className="backup-progress">
|
||||
<div className="progress-header">
|
||||
<p className="subtle tiny">
|
||||
Job: {pvc.last_job_name || 'n/a'}
|
||||
{pvc.last_job_started_at ? ` | Started ${formatTimestamp(pvc.last_job_started_at)}` : ''}
|
||||
</p>
|
||||
<span className={`chip ${progressClass}`}>{pvc.last_job_state || 'Unknown'}</span>
|
||||
</div>
|
||||
<div className="progress-track" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={progressPct}>
|
||||
<div className={`progress-fill ${progressClass} ${pvc.active_backups > 0 ? 'active' : ''}`} style={{ width: `${progressPct}%` }} />
|
||||
</div>
|
||||
<p className="subtle tiny">Progress {progressPct}% | Active jobs: {pvc.active_backups || 0}</p>
|
||||
</div>
|
||||
)}
|
||||
{pvc.error && <p className="error tiny">{pvc.error}</p>}
|
||||
<div className="actions">
|
||||
<button type="button" onClick={() => void triggerBackup(pvc.namespace, pvc.pvc)} disabled={busy}>
|
||||
Backup now
|
||||
</button>
|
||||
<button type="button" className="secondary" onClick={() => void openPVCSelection(pvc.namespace, pvc.pvc)}>
|
||||
Restore
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
<PVCInventoryPanel
|
||||
inventory={inventory}
|
||||
inventoryError={inventoryError}
|
||||
manualDedupe={manualDedupe}
|
||||
manualKeepLast={manualKeepLast}
|
||||
busy={busy}
|
||||
onManualDedupeChange={setManualDedupe}
|
||||
onManualKeepLastChange={setManualKeepLast}
|
||||
onTriggerNamespaceBackup={triggerNamespaceBackup}
|
||||
onOpenNamespaceSelection={openNamespaceSelection}
|
||||
onTriggerBackup={triggerBackup}
|
||||
onOpenPVCSelection={openPVCSelection}
|
||||
/>
|
||||
|
||||
<section className="column">
|
||||
<section className="panel">
|
||||
<div className="panel-header">
|
||||
<h2>B2 Consumption</h2>
|
||||
<button type="button" className="secondary" onClick={() => void refreshB2Usage()} disabled={b2Refreshing}>
|
||||
{b2Refreshing ? 'Refreshing...' : 'Refresh B2'}
|
||||
</button>
|
||||
</div>
|
||||
{b2Error && <p className="error">{b2Error}</p>}
|
||||
{!b2Error && !b2Usage.enabled && <p className="subtle">B2 monitoring is disabled in Soteria config.</p>}
|
||||
{!b2Error && b2Usage.enabled && !b2Usage.available && <p className="error">{b2Usage.error || 'B2 usage currently unavailable.'}</p>}
|
||||
{b2Usage.enabled && (
|
||||
<div className="stack">
|
||||
<p className="subtle tiny">Endpoint: {b2Usage.endpoint || 'n/a'} | Region: {b2Usage.region || 'n/a'}</p>
|
||||
<p className="subtle tiny">Last scan: {formatTimestamp(b2Usage.scanned_at)} | Duration: {b2Usage.scan_duration_ms || 0}ms</p>
|
||||
<div className="stat-grid">
|
||||
<div className="stat">
|
||||
<span className="label">Stored bytes</span>
|
||||
<strong>{formatBytes(b2Usage.total_bytes)}</strong>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="label">Objects</span>
|
||||
<strong>{b2Usage.total_objects}</strong>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="label">Recent bytes (24h)</span>
|
||||
<strong>{formatBytes(b2Usage.recent_bytes_24h)}</strong>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="label">Recent objects (24h)</span>
|
||||
<strong>{b2Usage.recent_objects_24h}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p className="subtle tiny">Recent 24h values are object-change bandwidth proxy from bucket scans. B2 egress billing totals are not exposed by S3 object listing.</p>
|
||||
<div className="bucket-table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Bucket</th>
|
||||
<th>Objects</th>
|
||||
<th>Stored</th>
|
||||
<th>Recent 24h</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(b2Usage.buckets || []).map((bucket) => (
|
||||
<tr key={bucket.name}>
|
||||
<td>
|
||||
<div>{bucket.name}</div>
|
||||
<div className="subtle tiny">Last object: {formatTimestamp(bucket.last_modified_at)}</div>
|
||||
</td>
|
||||
<td>{bucket.object_count}</td>
|
||||
<td>{formatBytes(bucket.total_bytes)}</td>
|
||||
<td>{formatBytes(bucket.recent_bytes_24h)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<h2>Restore Planner</h2>
|
||||
<p className="subtle tiny">Inventory restore buttons preload this panel. This is where restore dry-runs and execution happen.</p>
|
||||
{selection.kind === 'none' && <p className="subtle">Choose Restore on a PVC or namespace to begin.</p>}
|
||||
|
||||
{selection.kind === 'pvc' && (
|
||||
<div className="stack">
|
||||
<p className="subtle tiny"><strong>Source:</strong> {selection.namespace}/{selection.pvc} ({selection.volume})</p>
|
||||
<label>
|
||||
Backup snapshot
|
||||
<select value={restoreBackupURL} onChange={(event) => setRestoreBackupURL(event.target.value)}>
|
||||
{selection.backups.length === 0 && <option value="">No completed backups</option>}
|
||||
{selection.backups.map((item) => (
|
||||
<option key={item.url || item.name} value={item.url || ''}>
|
||||
{item.name} | {item.created || 'unknown time'} | {item.size || 'size n/a'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Target namespace
|
||||
<select value={restoreNamespace} onChange={(event) => setRestoreNamespace(event.target.value)}>
|
||||
{namespaceOptions.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Target PVC
|
||||
<input value={restorePVC} onChange={(event) => setRestorePVC(event.target.value)} />
|
||||
</label>
|
||||
<div className="actions">
|
||||
<button type="button" onClick={() => void runPVCRestore(false)} disabled={busy || !restoreBackupURL}>
|
||||
Create restore PVC
|
||||
</button>
|
||||
<button type="button" className="secondary" onClick={() => void runPVCRestore(true)} disabled={busy || !restoreBackupURL}>
|
||||
Dry run
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selection.kind === 'namespace' && (
|
||||
<div className="stack">
|
||||
<p className="subtle tiny"><strong>Source namespace:</strong> {selection.namespace}</p>
|
||||
<label>
|
||||
Target namespace
|
||||
<select value={namespaceRestoreTarget} onChange={(event) => setNamespaceRestoreTarget(event.target.value)}>
|
||||
{namespaceOptions.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Target PVC prefix
|
||||
<input value={namespaceRestorePrefix} onChange={(event) => setNamespaceRestorePrefix(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Snapshot hint (optional)
|
||||
<input value={namespaceRestoreSnapshot} onChange={(event) => setNamespaceRestoreSnapshot(event.target.value)} placeholder="blank = latest completed" />
|
||||
</label>
|
||||
<div className="actions">
|
||||
<button type="button" onClick={() => void runNamespaceRestore(false)} disabled={busy}>
|
||||
Create restore PVCs
|
||||
</button>
|
||||
<button type="button" className="secondary" onClick={() => void runNamespaceRestore(true)} disabled={busy}>
|
||||
Dry run
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<B2UsagePanel
|
||||
b2Usage={b2Usage}
|
||||
b2Error={b2Error}
|
||||
b2Refreshing={b2Refreshing}
|
||||
onRefresh={refreshB2Usage}
|
||||
/>
|
||||
<RestorePlannerPanel
|
||||
selection={selection}
|
||||
namespaceOptions={namespaceOptions}
|
||||
restoreNamespace={restoreNamespace}
|
||||
restorePVC={restorePVC}
|
||||
restoreBackupURL={restoreBackupURL}
|
||||
namespaceRestoreTarget={namespaceRestoreTarget}
|
||||
namespaceRestorePrefix={namespaceRestorePrefix}
|
||||
namespaceRestoreSnapshot={namespaceRestoreSnapshot}
|
||||
busy={busy}
|
||||
onRestoreNamespaceChange={setRestoreNamespace}
|
||||
onRestorePVCChange={setRestorePVC}
|
||||
onRestoreBackupURLChange={setRestoreBackupURL}
|
||||
onNamespaceRestoreTargetChange={setNamespaceRestoreTarget}
|
||||
onNamespaceRestorePrefixChange={setNamespaceRestorePrefix}
|
||||
onNamespaceRestoreSnapshotChange={setNamespaceRestoreSnapshot}
|
||||
onRunPVCRestore={runPVCRestore}
|
||||
onRunNamespaceRestore={runNamespaceRestore}
|
||||
/>
|
||||
<section className="panel action-panel">
|
||||
<h2>Last Action</h2>
|
||||
<pre>{lastAction}</pre>
|
||||
@ -832,83 +463,27 @@ function App() {
|
||||
</section>
|
||||
|
||||
<section className="column">
|
||||
<section className="panel scroll-panel">
|
||||
<h2>Backup Policies</h2>
|
||||
<p className="subtle tiny">Policy backups create new restic snapshots. `Keep last` controls version retention per PVC: 1 means only newest copy remains after each run. With dedupe on, unchanged blocks are reused in the shared repository. With dedupe off, Soteria isolates each PVC to its own repository path.</p>
|
||||
<div className="stack">
|
||||
<label>
|
||||
Namespace
|
||||
<select value={policyNamespace} onChange={(event) => setPolicyNamespace(event.target.value)}>
|
||||
{namespaceOptions.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
PVC (optional)
|
||||
<input value={policyPVC} onChange={(event) => setPolicyPVC(event.target.value)} placeholder="blank means all PVCs in namespace" />
|
||||
</label>
|
||||
<label>
|
||||
Interval hours
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={policyIntervalHours}
|
||||
onChange={(event) => setPolicyIntervalHours(Math.max(1, Number(event.target.value || 1)))}
|
||||
/>
|
||||
</label>
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" checked={policyEnabled} onChange={(event) => setPolicyEnabled(event.target.checked)} />
|
||||
Enabled
|
||||
</label>
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" checked={policyDedupe} onChange={(event) => setPolicyDedupe(event.target.checked)} />
|
||||
Dedupe unchanged blocks
|
||||
</label>
|
||||
<label>
|
||||
Keep last snapshots per PVC (0 = keep all)
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1000}
|
||||
value={policyKeepLast}
|
||||
onChange={(event) => setPolicyKeepLast(Math.max(0, Math.min(1000, Number(event.target.value || 0))))}
|
||||
/>
|
||||
</label>
|
||||
<button type="button" onClick={() => void savePolicy()} disabled={busy || !policyNamespace}>Save policy</button>
|
||||
</div>
|
||||
|
||||
{policyError && <p className="error">{policyError}</p>}
|
||||
{!policyError && policies.length === 0 && <p className="subtle">No policies yet.</p>}
|
||||
<div className="policy-list">
|
||||
{policies.map((policy) => (
|
||||
<article key={policy.id} className="policy-item">
|
||||
<div className="policy-head">
|
||||
<strong>{policy.namespace}/{policy.pvc || '*'}</strong>
|
||||
<span className={`chip ${policy.enabled ? 'good' : 'bad'}`}>{policy.enabled ? 'Enabled' : 'Disabled'}</span>
|
||||
</div>
|
||||
<p className="subtle tiny">Every {policy.interval_hours}h | Dedupe: {policy.dedupe === false ? 'off' : 'on'} | Keep last: {policy.keep_last ?? 0} | Updated {formatTimestamp(policy.updated_at || policy.created_at)}</p>
|
||||
<div className="actions">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary"
|
||||
onClick={() => {
|
||||
setPolicyNamespace(policy.namespace);
|
||||
setPolicyPVC(policy.pvc || '');
|
||||
setPolicyIntervalHours(policy.interval_hours);
|
||||
setPolicyEnabled(policy.enabled);
|
||||
setPolicyDedupe(policy.dedupe !== false);
|
||||
setPolicyKeepLast(policy.keep_last ?? 0);
|
||||
}}
|
||||
>
|
||||
Load
|
||||
</button>
|
||||
<button type="button" className="secondary" onClick={() => void deletePolicy(policy.id)} disabled={busy}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<BackupPoliciesPanel
|
||||
policies={policies}
|
||||
policyError={policyError}
|
||||
namespaceOptions={namespaceOptions}
|
||||
policyNamespace={policyNamespace}
|
||||
policyPVC={policyPVC}
|
||||
policyIntervalHours={policyIntervalHours}
|
||||
policyEnabled={policyEnabled}
|
||||
policyDedupe={policyDedupe}
|
||||
policyKeepLast={policyKeepLast}
|
||||
busy={busy}
|
||||
onPolicyNamespaceChange={setPolicyNamespace}
|
||||
onPolicyPVCChange={setPolicyPVC}
|
||||
onPolicyIntervalHoursChange={setPolicyIntervalHours}
|
||||
onPolicyEnabledChange={setPolicyEnabled}
|
||||
onPolicyDedupeChange={setPolicyDedupe}
|
||||
onPolicyKeepLastChange={setPolicyKeepLast}
|
||||
onSavePolicy={savePolicy}
|
||||
onDeletePolicy={deletePolicy}
|
||||
onLoadPolicy={loadPolicyIntoForm}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
78
web/src/components/B2UsagePanel.tsx
Normal file
78
web/src/components/B2UsagePanel.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import type { B2UsageResponse } from '../soteria-types';
|
||||
import { formatBytes, formatTimestamp } from '../soteria-ui-helpers';
|
||||
|
||||
interface B2UsagePanelProps {
|
||||
b2Usage: B2UsageResponse;
|
||||
b2Error: string;
|
||||
b2Refreshing: boolean;
|
||||
onRefresh: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function B2UsagePanel({ b2Usage, b2Error, b2Refreshing, onRefresh }: B2UsagePanelProps) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="panel-header">
|
||||
<h2>B2 Consumption</h2>
|
||||
<button type="button" className="secondary" onClick={() => void onRefresh()} disabled={b2Refreshing}>
|
||||
{b2Refreshing ? 'Refreshing...' : 'Refresh B2'}
|
||||
</button>
|
||||
</div>
|
||||
{b2Error && <p className="error">{b2Error}</p>}
|
||||
{!b2Error && !b2Usage.enabled && <p className="subtle">B2 monitoring is disabled in Soteria config.</p>}
|
||||
{!b2Error && b2Usage.enabled && !b2Usage.available && <p className="error">{b2Usage.error || 'B2 usage currently unavailable.'}</p>}
|
||||
{b2Usage.enabled && (
|
||||
<div className="stack">
|
||||
<p className="subtle tiny">Endpoint: {b2Usage.endpoint || 'n/a'} | Region: {b2Usage.region || 'n/a'}</p>
|
||||
<p className="subtle tiny">Last scan: {formatTimestamp(b2Usage.scanned_at)} | Duration: {b2Usage.scan_duration_ms || 0}ms</p>
|
||||
<div className="stat-grid">
|
||||
<div className="stat">
|
||||
<span className="label">Stored bytes</span>
|
||||
<strong>{formatBytes(b2Usage.total_bytes)}</strong>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="label">Objects</span>
|
||||
<strong>{b2Usage.total_objects}</strong>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="label">Recent bytes (24h)</span>
|
||||
<strong>{formatBytes(b2Usage.recent_bytes_24h)}</strong>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="label">Recent objects (24h)</span>
|
||||
<strong>{b2Usage.recent_objects_24h}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p className="subtle tiny">
|
||||
Recent 24h values are object-change bandwidth proxy from bucket scans. B2 egress billing totals are not exposed
|
||||
by S3 object listing.
|
||||
</p>
|
||||
<div className="bucket-table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Bucket</th>
|
||||
<th>Objects</th>
|
||||
<th>Stored</th>
|
||||
<th>Recent 24h</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(b2Usage.buckets || []).map((bucket) => (
|
||||
<tr key={bucket.name}>
|
||||
<td>
|
||||
<div>{bucket.name}</div>
|
||||
<div className="subtle tiny">Last object: {formatTimestamp(bucket.last_modified_at)}</div>
|
||||
</td>
|
||||
<td>{bucket.object_count}</td>
|
||||
<td>{formatBytes(bucket.total_bytes)}</td>
|
||||
<td>{formatBytes(bucket.recent_bytes_24h)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
122
web/src/components/BackupPoliciesPanel.tsx
Normal file
122
web/src/components/BackupPoliciesPanel.tsx
Normal file
@ -0,0 +1,122 @@
|
||||
import type { BackupPolicy } from '../soteria-types';
|
||||
import { formatTimestamp } from '../soteria-ui-helpers';
|
||||
|
||||
interface BackupPoliciesPanelProps {
|
||||
policies: BackupPolicy[];
|
||||
policyError: string;
|
||||
namespaceOptions: string[];
|
||||
policyNamespace: string;
|
||||
policyPVC: string;
|
||||
policyIntervalHours: number;
|
||||
policyEnabled: boolean;
|
||||
policyDedupe: boolean;
|
||||
policyKeepLast: number;
|
||||
busy: boolean;
|
||||
onPolicyNamespaceChange: (value: string) => void;
|
||||
onPolicyPVCChange: (value: string) => void;
|
||||
onPolicyIntervalHoursChange: (value: number) => void;
|
||||
onPolicyEnabledChange: (value: boolean) => void;
|
||||
onPolicyDedupeChange: (value: boolean) => void;
|
||||
onPolicyKeepLastChange: (value: number) => void;
|
||||
onSavePolicy: () => void | Promise<void>;
|
||||
onDeletePolicy: (policyID: string) => void | Promise<void>;
|
||||
onLoadPolicy: (policy: BackupPolicy) => void;
|
||||
}
|
||||
|
||||
export function BackupPoliciesPanel({
|
||||
policies,
|
||||
policyError,
|
||||
namespaceOptions,
|
||||
policyNamespace,
|
||||
policyPVC,
|
||||
policyIntervalHours,
|
||||
policyEnabled,
|
||||
policyDedupe,
|
||||
policyKeepLast,
|
||||
busy,
|
||||
onPolicyNamespaceChange,
|
||||
onPolicyPVCChange,
|
||||
onPolicyIntervalHoursChange,
|
||||
onPolicyEnabledChange,
|
||||
onPolicyDedupeChange,
|
||||
onPolicyKeepLastChange,
|
||||
onSavePolicy,
|
||||
onDeletePolicy,
|
||||
onLoadPolicy
|
||||
}: BackupPoliciesPanelProps) {
|
||||
return (
|
||||
<section className="panel scroll-panel">
|
||||
<h2>Backup Policies</h2>
|
||||
<p className="subtle tiny">
|
||||
Policy backups create new restic snapshots. `Keep last` controls version retention per PVC: 1 means only newest copy
|
||||
remains after each run. With dedupe on, unchanged blocks are reused in the shared repository. With dedupe off,
|
||||
Soteria isolates each PVC to its own repository path.
|
||||
</p>
|
||||
<div className="stack">
|
||||
<label>
|
||||
Namespace
|
||||
<select value={policyNamespace} onChange={(event) => onPolicyNamespaceChange(event.target.value)}>
|
||||
{namespaceOptions.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
PVC (optional)
|
||||
<input value={policyPVC} onChange={(event) => onPolicyPVCChange(event.target.value)} placeholder="blank means all PVCs in namespace" />
|
||||
</label>
|
||||
<label>
|
||||
Interval hours
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={policyIntervalHours}
|
||||
onChange={(event) => onPolicyIntervalHoursChange(Math.max(1, Number(event.target.value || 1)))}
|
||||
/>
|
||||
</label>
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" checked={policyEnabled} onChange={(event) => onPolicyEnabledChange(event.target.checked)} />
|
||||
Enabled
|
||||
</label>
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" checked={policyDedupe} onChange={(event) => onPolicyDedupeChange(event.target.checked)} />
|
||||
Dedupe unchanged blocks
|
||||
</label>
|
||||
<label>
|
||||
Keep last snapshots per PVC (0 = keep all)
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1000}
|
||||
value={policyKeepLast}
|
||||
onChange={(event) => onPolicyKeepLastChange(Math.max(0, Math.min(1000, Number(event.target.value || 0))))}
|
||||
/>
|
||||
</label>
|
||||
<button type="button" onClick={() => void onSavePolicy()} disabled={busy || !policyNamespace}>Save policy</button>
|
||||
</div>
|
||||
|
||||
{policyError && <p className="error">{policyError}</p>}
|
||||
{!policyError && policies.length === 0 && <p className="subtle">No policies yet.</p>}
|
||||
<div className="policy-list">
|
||||
{policies.map((policy) => (
|
||||
<article key={policy.id} className="policy-item">
|
||||
<div className="policy-head">
|
||||
<strong>{policy.namespace}/{policy.pvc || '*'}</strong>
|
||||
<span className={`chip ${policy.enabled ? 'good' : 'bad'}`}>{policy.enabled ? 'Enabled' : 'Disabled'}</span>
|
||||
</div>
|
||||
<p className="subtle tiny">
|
||||
Every {policy.interval_hours}h | Dedupe: {policy.dedupe === false ? 'off' : 'on'} | Keep last: {policy.keep_last ?? 0}
|
||||
{' '}| Updated {formatTimestamp(policy.updated_at || policy.created_at)}
|
||||
</p>
|
||||
<div className="actions">
|
||||
<button type="button" className="secondary" onClick={() => onLoadPolicy(policy)}>
|
||||
Load
|
||||
</button>
|
||||
<button type="button" className="secondary" onClick={() => void onDeletePolicy(policy.id)} disabled={busy}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
136
web/src/components/PVCInventoryPanel.tsx
Normal file
136
web/src/components/PVCInventoryPanel.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import type { InventoryResponse } from '../soteria-types';
|
||||
import { formatBytes, formatLabel, formatTimestamp, progressChipClass } from '../soteria-ui-helpers';
|
||||
|
||||
interface PVCInventoryPanelProps {
|
||||
inventory: InventoryResponse | null;
|
||||
inventoryError: string;
|
||||
manualDedupe: boolean;
|
||||
manualKeepLast: number;
|
||||
busy: boolean;
|
||||
onManualDedupeChange: (value: boolean) => void;
|
||||
onManualKeepLastChange: (value: number) => void;
|
||||
onTriggerNamespaceBackup: (namespace: string) => void | Promise<void>;
|
||||
onOpenNamespaceSelection: (namespace: string) => void;
|
||||
onTriggerBackup: (namespace: string, pvc: string) => void | Promise<void>;
|
||||
onOpenPVCSelection: (namespace: string, pvc: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function PVCInventoryPanel({
|
||||
inventory,
|
||||
inventoryError,
|
||||
manualDedupe,
|
||||
manualKeepLast,
|
||||
busy,
|
||||
onManualDedupeChange,
|
||||
onManualKeepLastChange,
|
||||
onTriggerNamespaceBackup,
|
||||
onOpenNamespaceSelection,
|
||||
onTriggerBackup,
|
||||
onOpenPVCSelection
|
||||
}: PVCInventoryPanelProps) {
|
||||
return (
|
||||
<section className="panel scroll-panel">
|
||||
<div className="panel-header">
|
||||
<h2>PVC Inventory</h2>
|
||||
<span className="subtle">
|
||||
{inventory?.generated_at ? `Updated ${formatTimestamp(inventory.generated_at)}` : 'No inventory yet'}
|
||||
</span>
|
||||
</div>
|
||||
<label className="checkbox-row">
|
||||
<input type="checkbox" checked={manualDedupe} onChange={(event) => onManualDedupeChange(event.target.checked)} />
|
||||
Dedupe unchanged blocks (default)
|
||||
</label>
|
||||
<label>
|
||||
Keep last snapshots per PVC (0 = keep all)
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1000}
|
||||
value={manualKeepLast}
|
||||
onChange={(event) => onManualKeepLastChange(Math.max(0, Math.min(1000, Number(event.target.value || 0))))}
|
||||
/>
|
||||
</label>
|
||||
<p className="subtle tiny">This setting applies to both `Backup now` and `Backup namespace` actions.</p>
|
||||
{inventoryError && <p className="error">{inventoryError}</p>}
|
||||
{!inventory && !inventoryError && <p className="subtle">Loading inventory...</p>}
|
||||
{inventory?.namespaces.map((namespace) => (
|
||||
<article key={namespace.name} className="namespace-block">
|
||||
<div className="namespace-row">
|
||||
<h3>{namespace.name}</h3>
|
||||
<div className="actions">
|
||||
<button type="button" className="secondary" onClick={() => void onTriggerNamespaceBackup(namespace.name)} disabled={busy}>
|
||||
Backup namespace
|
||||
</button>
|
||||
<button type="button" className="secondary" onClick={() => onOpenNamespaceSelection(namespace.name)}>
|
||||
Restore namespace
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pvc-grid">
|
||||
{namespace.pvcs.map((pvc) => {
|
||||
const healthClass = pvc.healthy ? 'good' : (pvc.health_reason === 'in_progress' ? 'warn' : 'bad');
|
||||
const healthLabel = pvc.healthy ? 'Healthy' : formatLabel(pvc.health_reason);
|
||||
const progressPct = Math.max(0, Math.min(100, Number(pvc.last_job_progress_pct || 0)));
|
||||
const progressClass = progressChipClass(pvc.last_job_state);
|
||||
const showProgress = Boolean(pvc.last_job_name) || (pvc.active_backups || 0) > 0;
|
||||
const latestSizeLabel = formatBytes(pvc.last_backup_size_bytes);
|
||||
const totalStoredLabel = formatBytes(pvc.total_backup_size_bytes);
|
||||
const showResticSizeHint = pvc.driver === 'restic'
|
||||
&& (pvc.last_backup_size_bytes === undefined || pvc.total_backup_size_bytes === undefined);
|
||||
|
||||
return (
|
||||
<article key={`${pvc.namespace}/${pvc.pvc}`} className="pvc-card">
|
||||
<div className="pvc-title-row">
|
||||
<div>
|
||||
<h4>{pvc.pvc}</h4>
|
||||
<p className="subtle tiny">
|
||||
{pvc.volume || 'unknown volume'} | {pvc.storage_class || 'no class'} | {pvc.capacity || 'unknown size'}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`chip ${healthClass}`}>{healthLabel}</span>
|
||||
</div>
|
||||
<p className="subtle tiny">
|
||||
Last backup: {pvc.last_backup_at ? `${formatTimestamp(pvc.last_backup_at)} (${(pvc.last_backup_age_hours || 0).toFixed(1)}h ago)` : 'never'}
|
||||
</p>
|
||||
<p className="subtle tiny">
|
||||
Backups: {pvc.completed_backups}/{pvc.backup_count} completed | Latest size: {latestSizeLabel} | Total stored: {totalStoredLabel}
|
||||
</p>
|
||||
{showResticSizeHint && (
|
||||
<p className="subtle tiny">
|
||||
Per-PVC storage is estimated from restic upload summaries persisted by Soteria. Older backups created
|
||||
before tracking may show n/a until a new backup runs.
|
||||
</p>
|
||||
)}
|
||||
{showProgress && (
|
||||
<div className="backup-progress">
|
||||
<div className="progress-header">
|
||||
<p className="subtle tiny">
|
||||
Job: {pvc.last_job_name || 'n/a'}
|
||||
{pvc.last_job_started_at ? ` | Started ${formatTimestamp(pvc.last_job_started_at)}` : ''}
|
||||
</p>
|
||||
<span className={`chip ${progressClass}`}>{pvc.last_job_state || 'Unknown'}</span>
|
||||
</div>
|
||||
<div className="progress-track" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={progressPct}>
|
||||
<div className={`progress-fill ${progressClass} ${pvc.active_backups > 0 ? 'active' : ''}`} style={{ width: `${progressPct}%` }} />
|
||||
</div>
|
||||
<p className="subtle tiny">Progress {progressPct}% | Active jobs: {pvc.active_backups || 0}</p>
|
||||
</div>
|
||||
)}
|
||||
{pvc.error && <p className="error tiny">{pvc.error}</p>}
|
||||
<div className="actions">
|
||||
<button type="button" onClick={() => void onTriggerBackup(pvc.namespace, pvc.pvc)} disabled={busy}>
|
||||
Backup now
|
||||
</button>
|
||||
<button type="button" className="secondary" onClick={() => void onOpenPVCSelection(pvc.namespace, pvc.pvc)}>
|
||||
Restore
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
116
web/src/components/RestorePlannerPanel.tsx
Normal file
116
web/src/components/RestorePlannerPanel.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
import type { RestoreSelection } from '../soteria-types';
|
||||
|
||||
interface RestorePlannerPanelProps {
|
||||
selection: RestoreSelection;
|
||||
namespaceOptions: string[];
|
||||
restoreNamespace: string;
|
||||
restorePVC: string;
|
||||
restoreBackupURL: string;
|
||||
namespaceRestoreTarget: string;
|
||||
namespaceRestorePrefix: string;
|
||||
namespaceRestoreSnapshot: string;
|
||||
busy: boolean;
|
||||
onRestoreNamespaceChange: (value: string) => void;
|
||||
onRestorePVCChange: (value: string) => void;
|
||||
onRestoreBackupURLChange: (value: string) => void;
|
||||
onNamespaceRestoreTargetChange: (value: string) => void;
|
||||
onNamespaceRestorePrefixChange: (value: string) => void;
|
||||
onNamespaceRestoreSnapshotChange: (value: string) => void;
|
||||
onRunPVCRestore: (dryRun: boolean) => void | Promise<void>;
|
||||
onRunNamespaceRestore: (dryRun: boolean) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function RestorePlannerPanel({
|
||||
selection,
|
||||
namespaceOptions,
|
||||
restoreNamespace,
|
||||
restorePVC,
|
||||
restoreBackupURL,
|
||||
namespaceRestoreTarget,
|
||||
namespaceRestorePrefix,
|
||||
namespaceRestoreSnapshot,
|
||||
busy,
|
||||
onRestoreNamespaceChange,
|
||||
onRestorePVCChange,
|
||||
onRestoreBackupURLChange,
|
||||
onNamespaceRestoreTargetChange,
|
||||
onNamespaceRestorePrefixChange,
|
||||
onNamespaceRestoreSnapshotChange,
|
||||
onRunPVCRestore,
|
||||
onRunNamespaceRestore
|
||||
}: RestorePlannerPanelProps) {
|
||||
return (
|
||||
<section className="panel">
|
||||
<h2>Restore Planner</h2>
|
||||
<p className="subtle tiny">Inventory restore buttons preload this panel. This is where restore dry-runs and execution happen.</p>
|
||||
{selection.kind === 'none' && <p className="subtle">Choose Restore on a PVC or namespace to begin.</p>}
|
||||
|
||||
{selection.kind === 'pvc' && (
|
||||
<div className="stack">
|
||||
<p className="subtle tiny"><strong>Source:</strong> {selection.namespace}/{selection.pvc} ({selection.volume})</p>
|
||||
<label>
|
||||
Backup snapshot
|
||||
<select value={restoreBackupURL} onChange={(event) => onRestoreBackupURLChange(event.target.value)}>
|
||||
{selection.backups.length === 0 && <option value="">No completed backups</option>}
|
||||
{selection.backups.map((item) => (
|
||||
<option key={item.url || item.name} value={item.url || ''}>
|
||||
{item.name} | {item.created || 'unknown time'} | {item.size || 'size n/a'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Target namespace
|
||||
<select value={restoreNamespace} onChange={(event) => onRestoreNamespaceChange(event.target.value)}>
|
||||
{namespaceOptions.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Target PVC
|
||||
<input value={restorePVC} onChange={(event) => onRestorePVCChange(event.target.value)} />
|
||||
</label>
|
||||
<div className="actions">
|
||||
<button type="button" onClick={() => void onRunPVCRestore(false)} disabled={busy || !restoreBackupURL}>
|
||||
Create restore PVC
|
||||
</button>
|
||||
<button type="button" className="secondary" onClick={() => void onRunPVCRestore(true)} disabled={busy || !restoreBackupURL}>
|
||||
Dry run
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selection.kind === 'namespace' && (
|
||||
<div className="stack">
|
||||
<p className="subtle tiny"><strong>Source namespace:</strong> {selection.namespace}</p>
|
||||
<label>
|
||||
Target namespace
|
||||
<select value={namespaceRestoreTarget} onChange={(event) => onNamespaceRestoreTargetChange(event.target.value)}>
|
||||
{namespaceOptions.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Target PVC prefix
|
||||
<input value={namespaceRestorePrefix} onChange={(event) => onNamespaceRestorePrefixChange(event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
Snapshot hint (optional)
|
||||
<input
|
||||
value={namespaceRestoreSnapshot}
|
||||
onChange={(event) => onNamespaceRestoreSnapshotChange(event.target.value)}
|
||||
placeholder="blank = latest completed"
|
||||
/>
|
||||
</label>
|
||||
<div className="actions">
|
||||
<button type="button" onClick={() => void onRunNamespaceRestore(false)} disabled={busy}>
|
||||
Create restore PVCs
|
||||
</button>
|
||||
<button type="button" className="secondary" onClick={() => void onRunNamespaceRestore(true)} disabled={busy}>
|
||||
Dry run
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
114
web/src/soteria-types.ts
Normal file
114
web/src/soteria-types.ts
Normal file
@ -0,0 +1,114 @@
|
||||
export interface AuthInfo {
|
||||
authenticated: boolean;
|
||||
user?: string;
|
||||
email?: string;
|
||||
groups?: string[];
|
||||
allowed_groups?: string[];
|
||||
}
|
||||
|
||||
export interface BackupRecord {
|
||||
name: string;
|
||||
snapshot_name?: string;
|
||||
created?: string;
|
||||
state?: string;
|
||||
url?: string;
|
||||
size?: string;
|
||||
latest?: boolean;
|
||||
}
|
||||
|
||||
export interface BackupListResponse {
|
||||
namespace: string;
|
||||
pvc: string;
|
||||
volume: string;
|
||||
backups: BackupRecord[];
|
||||
}
|
||||
|
||||
export interface PVCInventory {
|
||||
namespace: string;
|
||||
pvc: string;
|
||||
volume?: string;
|
||||
phase?: string;
|
||||
storage_class?: string;
|
||||
capacity?: string;
|
||||
access_modes?: string[];
|
||||
driver?: string;
|
||||
last_backup_at?: string;
|
||||
last_backup_age_hours?: number;
|
||||
backup_count: number;
|
||||
completed_backups: number;
|
||||
active_backups: number;
|
||||
last_job_name?: string;
|
||||
last_job_state?: string;
|
||||
last_job_started_at?: string;
|
||||
last_job_progress_pct: number;
|
||||
last_backup_size_bytes?: number;
|
||||
total_backup_size_bytes?: number;
|
||||
healthy: boolean;
|
||||
health_reason?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface NamespaceInventory {
|
||||
name: string;
|
||||
pvcs: PVCInventory[];
|
||||
}
|
||||
|
||||
export interface InventoryResponse {
|
||||
generated_at: string;
|
||||
namespaces: NamespaceInventory[];
|
||||
}
|
||||
|
||||
export interface BackupPolicy {
|
||||
id: string;
|
||||
namespace: string;
|
||||
pvc?: string;
|
||||
interval_hours: number;
|
||||
enabled: boolean;
|
||||
dedupe?: boolean;
|
||||
keep_last?: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface BackupPolicyListResponse {
|
||||
policies: BackupPolicy[];
|
||||
}
|
||||
|
||||
export interface B2BucketUsage {
|
||||
name: string;
|
||||
object_count: number;
|
||||
total_bytes: number;
|
||||
recent_objects_24h: number;
|
||||
recent_bytes_24h: number;
|
||||
last_modified_at?: string;
|
||||
}
|
||||
|
||||
export interface B2UsageResponse {
|
||||
enabled: boolean;
|
||||
available: boolean;
|
||||
endpoint?: string;
|
||||
region?: string;
|
||||
scanned_at?: string;
|
||||
scan_duration_ms?: number;
|
||||
total_objects: number;
|
||||
total_bytes: number;
|
||||
recent_objects_24h: number;
|
||||
recent_bytes_24h: number;
|
||||
buckets?: B2BucketUsage[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type RestoreSelection =
|
||||
| { kind: 'none' }
|
||||
| { kind: 'pvc'; namespace: string; pvc: string; volume: string; backups: BackupRecord[] }
|
||||
| { kind: 'namespace'; namespace: string };
|
||||
|
||||
export const EMPTY_B2: B2UsageResponse = {
|
||||
enabled: false,
|
||||
available: false,
|
||||
total_objects: 0,
|
||||
total_bytes: 0,
|
||||
recent_objects_24h: 0,
|
||||
recent_bytes_24h: 0,
|
||||
buckets: []
|
||||
};
|
||||
126
web/src/soteria-ui-helpers.ts
Normal file
126
web/src/soteria-ui-helpers.ts
Normal file
@ -0,0 +1,126 @@
|
||||
export function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
window.setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
function looksLikeHTML(value: string): boolean {
|
||||
const sample = value.trim().slice(0, 512).toLowerCase();
|
||||
return sample.startsWith('<!doctype html') || sample.includes('<html');
|
||||
}
|
||||
|
||||
function extractHTMLTitle(value: string): string {
|
||||
const match = value.match(/<title>\s*([^<]+)\s*<\/title>/i);
|
||||
return match?.[1]?.trim() || '';
|
||||
}
|
||||
|
||||
function extractRequestID(value: string): string {
|
||||
const match = value.match(/Request ID:\s*([0-9a-f-]+)/i);
|
||||
return match?.[1]?.trim() || '';
|
||||
}
|
||||
|
||||
export async function fetchJSON<T>(input: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(input, init);
|
||||
const text = await response.text();
|
||||
let payload: unknown = {};
|
||||
if (text.trim() !== '') {
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
payload = { error: text };
|
||||
}
|
||||
}
|
||||
if (!response.ok) {
|
||||
let message = typeof payload === 'object' && payload !== null && 'error' in payload
|
||||
? String((payload as { error: unknown }).error)
|
||||
: `${response.status} ${response.statusText}`;
|
||||
if (looksLikeHTML(text)) {
|
||||
const title = extractHTMLTitle(text);
|
||||
const requestID = extractRequestID(text);
|
||||
message = `upstream gateway error (${response.status}${title ? ` ${title}` : ''})`;
|
||||
if (requestID) {
|
||||
message = `${message}; request id ${requestID}`;
|
||||
}
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
export function formatBytes(value?: number): string {
|
||||
if (value === undefined || value === null || Number.isNaN(value)) {
|
||||
return 'n/a';
|
||||
}
|
||||
if (value <= 0) {
|
||||
return '0 B';
|
||||
}
|
||||
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
|
||||
let size = value;
|
||||
let unit = 0;
|
||||
while (size >= 1024 && unit < units.length - 1) {
|
||||
size /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${size.toFixed(unit === 0 ? 0 : 2)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
export function formatTimestamp(value?: string): string {
|
||||
if (!value) {
|
||||
return 'n/a';
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.valueOf())) {
|
||||
return value;
|
||||
}
|
||||
return parsed.toLocaleString();
|
||||
}
|
||||
|
||||
export function formatLabel(value?: string): string {
|
||||
if (!value) {
|
||||
return 'Needs attention';
|
||||
}
|
||||
return value.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
export function progressChipClass(state?: string): 'good' | 'warn' | 'bad' {
|
||||
switch ((state || '').toLowerCase()) {
|
||||
case 'completed':
|
||||
return 'good';
|
||||
case 'failed':
|
||||
return 'bad';
|
||||
default:
|
||||
return 'warn';
|
||||
}
|
||||
}
|
||||
|
||||
export function suggestTargetPVCName(sourcePVC: string): string {
|
||||
const now = new Date();
|
||||
const pad = (item: number) => String(item).padStart(2, '0');
|
||||
const stamp = [
|
||||
now.getUTCFullYear(),
|
||||
pad(now.getUTCMonth() + 1),
|
||||
pad(now.getUTCDate()),
|
||||
pad(now.getUTCHours()),
|
||||
pad(now.getUTCMinutes())
|
||||
].join('');
|
||||
return (`restore-${sourcePVC}-${stamp}`)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 63)
|
||||
.replace(/-+$/g, '');
|
||||
}
|
||||
|
||||
export function suggestNamespacePrefix(): string {
|
||||
const now = new Date();
|
||||
const pad = (item: number) => String(item).padStart(2, '0');
|
||||
const stamp = [
|
||||
now.getUTCFullYear(),
|
||||
pad(now.getUTCMonth() + 1),
|
||||
pad(now.getUTCDate()),
|
||||
pad(now.getUTCHours()),
|
||||
pad(now.getUTCMinutes())
|
||||
].join('');
|
||||
return `restore-${stamp}-`;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user