Skip to content

Commit 2e9a01b

Browse files
authored
Merge pull request #9 from achetronic/feat/add-backup-restore-capabilities
feat: Add Settings menu to perform backups / restores
2 parents cbf97d5 + 6bde186 commit 2e9a01b

18 files changed

Lines changed: 726 additions & 3 deletions

File tree

.agents/MULTI_AGENT_ADMIN_API.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,15 @@ Client types: `direct`, `telegram`, `cron`, `webhook`. See [CLIENT_DESIGN.md](CL
125125
|--------|------|-------------|
126126
| POST | `/api/v1/webhooks/{clientId}` | Fire a webhook — Bearer token auth, passthrough or fixed command |
127127

128+
### Backup & Restore
129+
130+
| Method | Path | Description |
131+
|--------|------|-------------|
132+
| GET | `/settings/backup` | Download a `.tar.gz` archive of the entire `data/` directory |
133+
| POST | `/settings/restore` | Upload a `.tar.gz` to atomically replace all data (500MB limit) |
134+
135+
The backup archive contains `store.json`, `conversations.json`, and `skills/{id}/` files. On restore, the archive must contain a valid `store.json` at the root level. The current data directory is atomically swapped (rename) and both stores are reloaded in memory.
136+
128137
## Persistence
129138

130139
- Store persists to `data/store.json` on each write

frontend/admin-ui/src/App.vue

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
<ClientsList v-else-if="activeTab === 'clients'" />
3636
<SecretsList v-else-if="activeTab === 'secrets'" />
3737
<ConversationsView v-else-if="activeTab === 'conversations'" />
38+
<SettingsView v-else-if="activeTab === 'settings'" />
3839
</div>
3940
</Transition>
4041
</main>
@@ -70,13 +71,14 @@ import SkillsList from './views/skills/SkillsList.vue'
7071
import ClientsList from './views/clients/ClientsList.vue'
7172
import SecretsList from './views/secrets/SecretsList.vue'
7273
import ConversationsView from './views/conversations/ConversationsView.vue'
74+
import SettingsView from './views/settings/SettingsView.vue'
7375
7476
const store = useDataStore()
7577
7678
const showLogin = ref(false)
7779
const appReady = ref(false)
7880
79-
const validTabs = ['backends', 'memory', 'mcps', 'agents', 'flows', 'commands', 'skills', 'clients', 'secrets', 'conversations']
81+
const validTabs = ['backends', 'memory', 'mcps', 'agents', 'flows', 'commands', 'skills', 'clients', 'secrets', 'conversations', 'settings']
8082
const saved = location.hash.slice(1)
8183
const activeTab = ref(validTabs.includes(saved) ? saved : 'backends')
8284
const sidebarCollapsed = ref(localStorage.getItem('sidebar-collapsed') === 'true')
@@ -102,7 +104,6 @@ watch(activeTab, (tab) => {
102104
103105
const toastRef = ref(null)
104106
const searchRef = ref(null)
105-
106107
function toast(message, type) {
107108
toastRef.value?.show(message, type)
108109
}

frontend/admin-ui/src/components/Card.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const colorMap = {
2626
lava: 'hover:border-lava-500/15 hover:shadow-[0_0_15px_-3px_rgba(239,68,68,0.04)]',
2727
teal: 'hover:border-teal-500/15 hover:shadow-[0_0_15px_-3px_rgba(20,184,166,0.04)]',
2828
amber: 'hover:border-amber-500/15 hover:shadow-[0_0_15px_-3px_rgba(245,158,11,0.04)]',
29+
blue: 'hover:border-blue-500/15 hover:shadow-[0_0_15px_-3px_rgba(59,130,246,0.04)]',
2930
}
3031
3132
const hoverClass = computed(() => {

frontend/admin-ui/src/components/Icon.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ const paths = {
4444
automation: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065zM15 12a3 3 0 11-6 0 3 3 0 016 0z',
4545
chat: 'M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z',
4646
download: 'M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4',
47+
upload: 'M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12',
4748
key: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z',
4849
skill: 'M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z',
4950
}

frontend/admin-ui/src/components/Sidebar.vue

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,12 @@ const groups = [
122122
{ id: 'conversations', label: 'Conversations', icon: 'chat', color: 'teal' },
123123
],
124124
},
125+
{
126+
label: 'System',
127+
items: [
128+
{ id: 'settings', label: 'Settings', icon: 'automation', color: 'blue' },
129+
],
130+
},
125131
]
126132
127133
function itemCount(id) {
@@ -152,6 +158,7 @@ function activeClasses(color) {
152158
lava: 'bg-lava-500/10 text-lava-200',
153159
amber: 'bg-amber-500/10 text-amber-200',
154160
arena: 'bg-arena-500/10 text-arena-200',
161+
blue: 'bg-blue-500/10 text-blue-200',
155162
}
156163
return map[color] || map.sol
157164
}
@@ -169,6 +176,7 @@ function iconBgClasses(color) {
169176
lava: 'bg-lava-500/20 text-lava-300',
170177
amber: 'bg-amber-500/20 text-amber-300',
171178
arena: 'bg-arena-500/20 text-arena-300',
179+
blue: 'bg-blue-500/20 text-blue-300',
172180
}
173181
return map[color] || map.sol
174182
}
@@ -186,6 +194,7 @@ function countActiveClasses(color) {
186194
lava: 'text-lava-300 bg-lava-500/15',
187195
amber: 'text-amber-300 bg-amber-500/15',
188196
arena: 'text-arena-300 bg-arena-500/15',
197+
blue: 'text-blue-300 bg-blue-500/15',
189198
}
190199
return map[color] || map.sol
191200
}

frontend/admin-ui/src/components/TopBar.vue

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ const sections = {
8989
commands: { label: 'Commands', icon: 'command', color: 'indigo', group: 'Agents' },
9090
clients: { label: 'Clients', icon: 'phone', color: 'lava', group: 'Connections' },
9191
conversations: { label: 'Conversations', icon: 'chat', color: 'teal', group: 'Audit' },
92+
settings: { label: 'Settings', icon: 'automation', color: 'blue', group: 'System' },
9293
}
9394
9495
const section = computed(() => sections[props.activeTab] || sections.backends)
@@ -105,6 +106,8 @@ const sectionIconBg = computed(() => {
105106
teal: 'bg-teal-500/15',
106107
lava: 'bg-lava-500/15',
107108
amber: 'bg-amber-500/15',
109+
arena: 'bg-arena-500/15',
110+
blue: 'bg-blue-500/15',
108111
}
109112
return map[section.value.color] || map.sol
110113
})
@@ -121,6 +124,8 @@ const sectionIconText = computed(() => {
121124
teal: 'text-teal-400',
122125
lava: 'text-lava-400',
123126
amber: 'text-amber-400',
127+
arena: 'text-arena-400',
128+
blue: 'text-blue-400',
124129
}
125130
return map[section.value.color] || map.sol
126131
})
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { getAuthHeaders } from '../auth.js'
2+
3+
const BASE = '/api/v1/admin'
4+
5+
export const backupApi = {
6+
async download() {
7+
const res = await fetch(`${BASE}/settings/backup`, { headers: getAuthHeaders() })
8+
if (!res.ok) {
9+
const data = await res.json().catch(() => ({}))
10+
throw new Error(data.error || `HTTP ${res.status}`)
11+
}
12+
const blob = await res.blob()
13+
const cd = res.headers.get('content-disposition') || ''
14+
const match = cd.match(/filename="?([^"]+)"?/)
15+
const filename = match ? match[1] : 'magec-backup.tar.gz'
16+
17+
const a = document.createElement('a')
18+
a.href = URL.createObjectURL(blob)
19+
a.download = filename
20+
a.click()
21+
URL.revokeObjectURL(a.href)
22+
},
23+
24+
async restore(file) {
25+
const res = await fetch(`${BASE}/settings/restore`, {
26+
method: 'POST',
27+
headers: { ...getAuthHeaders(), 'Content-Type': 'application/gzip' },
28+
body: file,
29+
})
30+
const data = await res.json()
31+
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`)
32+
return data
33+
},
34+
}

frontend/admin-ui/src/lib/api/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ export { skillsApi } from './skills.js'
99
export { conversationsApi } from './conversations.js'
1010
export { settingsApi } from './settings.js'
1111
export { secretsApi } from './secrets.js'
12+
export { backupApi } from './backup.js'
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
<template>
2+
<div class="max-w-2xl space-y-6">
3+
<!-- Header -->
4+
<div>
5+
<h3 class="text-sm font-semibold text-arena-100">Backup & Restore</h3>
6+
<p class="text-xs text-arena-500 mt-1">Download a full backup of your data or restore from a previous backup file.</p>
7+
</div>
8+
9+
<!-- Backup -->
10+
<Card color="blue">
11+
<div class="flex items-start gap-4">
12+
<div class="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 bg-blue-500/10">
13+
<Icon name="download" size="md" class="text-blue-400" />
14+
</div>
15+
<div class="flex-1 min-w-0">
16+
<h4 class="text-[13px] font-medium text-arena-100">Download Backup</h4>
17+
<p class="text-xs text-arena-500 mt-0.5">
18+
Exports all agents, flows, skills, backends, clients, secrets, commands, memory providers, and conversations as a <code class="text-arena-400 bg-piedra-800/80 px-1 rounded">.tar.gz</code> archive.
19+
</p>
20+
<button
21+
@click="onBackup"
22+
:disabled="backupLoading"
23+
class="mt-3 px-4 py-1.5 bg-blue-500/15 hover:bg-blue-500/25 text-blue-300 text-xs font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
24+
>
25+
{{ backupLoading ? 'Downloading...' : 'Download Backup' }}
26+
</button>
27+
</div>
28+
</div>
29+
</Card>
30+
31+
<!-- Restore -->
32+
<Card color="blue">
33+
<div class="flex items-start gap-4">
34+
<div class="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 bg-blue-500/10">
35+
<Icon name="upload" size="md" class="text-blue-400" />
36+
</div>
37+
<div class="flex-1 min-w-0">
38+
<h4 class="text-[13px] font-medium text-arena-100">Restore from Backup</h4>
39+
<p class="text-xs text-arena-500 mt-0.5">
40+
Upload a previously downloaded <code class="text-arena-400 bg-piedra-800/80 px-1 rounded">.tar.gz</code> backup to replace all current data. This action cannot be undone.
41+
</p>
42+
<div class="flex items-center gap-3 mt-3">
43+
<button
44+
@click="triggerRestore"
45+
:disabled="restoreLoading"
46+
class="px-4 py-1.5 bg-blue-500/15 hover:bg-blue-500/25 text-blue-300 text-xs font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
47+
>
48+
{{ restoreLoading ? 'Restoring...' : 'Upload & Restore' }}
49+
</button>
50+
<span v-if="restoreFile" class="text-[11px] text-arena-400 truncate">{{ restoreFile.name }}</span>
51+
</div>
52+
<input ref="fileInput" type="file" accept=".tar.gz,.tgz" class="hidden" @change="onFileSelected" />
53+
</div>
54+
</div>
55+
</Card>
56+
57+
<!-- Warning -->
58+
<div class="flex items-start gap-3 p-3 rounded-lg bg-lava-500/5 border border-lava-500/10">
59+
<svg class="w-4 h-4 text-lava-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
60+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
61+
</svg>
62+
<div>
63+
<p class="text-xs font-medium text-lava-300">Restoring replaces everything</p>
64+
<p class="text-[11px] text-lava-400/70 mt-0.5">All current agents, flows, skills, conversations, secrets, and configuration will be overwritten. Consider downloading a backup first.</p>
65+
</div>
66+
</div>
67+
</div>
68+
</template>
69+
70+
<script setup>
71+
import { ref, inject } from 'vue'
72+
import { backupApi } from '../../lib/api/index.js'
73+
import { useDataStore } from '../../lib/stores/data.js'
74+
import Card from '../../components/Card.vue'
75+
import Icon from '../../components/Icon.vue'
76+
77+
const store = useDataStore()
78+
const toast = inject('toast')
79+
const requestDelete = inject('requestDelete')
80+
81+
const backupLoading = ref(false)
82+
const restoreLoading = ref(false)
83+
const restoreFile = ref(null)
84+
const fileInput = ref(null)
85+
86+
async function onBackup() {
87+
backupLoading.value = true
88+
try {
89+
await backupApi.download()
90+
toast.success('Backup downloaded')
91+
} catch (e) {
92+
toast.error('Backup failed: ' + e.message)
93+
} finally {
94+
backupLoading.value = false
95+
}
96+
}
97+
98+
function triggerRestore() {
99+
fileInput.value.value = ''
100+
fileInput.value.click()
101+
}
102+
103+
function onFileSelected(e) {
104+
const file = e.target.files?.[0]
105+
if (!file) return
106+
restoreFile.value = file
107+
108+
requestDelete(
109+
'This will replace ALL data (agents, flows, skills, conversations, secrets). Are you sure?',
110+
async () => {
111+
restoreLoading.value = true
112+
try {
113+
await backupApi.restore(file)
114+
toast.success('Backup restored — reloading data')
115+
store.refresh()
116+
} catch (err) {
117+
toast.error('Restore failed: ' + err.message)
118+
} finally {
119+
restoreLoading.value = false
120+
restoreFile.value = null
121+
}
122+
}
123+
)
124+
}
125+
</script>
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<template>
2+
<div class="max-w-3xl mx-auto px-4 sm:px-6 py-5 space-y-8">
3+
<!-- Search -->
4+
<div class="sticky top-0 z-10 -mx-4 sm:-mx-6 px-4 sm:px-6 pt-0 pb-4 bg-gradient-to-b from-piedra-950 via-piedra-950 to-transparent">
5+
<div class="flex items-center gap-2.5 px-3 py-2 rounded-lg border border-piedra-700/50 bg-piedra-900/80 backdrop-blur-sm focus-within:border-arena-600/50 transition-colors">
6+
<svg class="w-3.5 h-3.5 text-arena-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
7+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
8+
</svg>
9+
<input
10+
v-model="search"
11+
type="text"
12+
class="flex-1 bg-transparent text-sm text-arena-100 placeholder-arena-500 outline-none"
13+
placeholder="Search settings..."
14+
/>
15+
<kbd v-if="!search" class="hidden sm:inline-flex px-1.5 py-0.5 text-[9px] font-mono text-arena-600 bg-piedra-800 border border-piedra-700/50 rounded">/</kbd>
16+
<button v-else @click="search = ''" class="text-arena-500 hover:text-arena-300 transition-colors">
17+
<Icon name="close" size="xs" />
18+
</button>
19+
</div>
20+
</div>
21+
22+
<!-- Sections -->
23+
<template v-for="section in filteredSections" :key="section.id">
24+
<section :ref="el => sectionRefs[section.id] = el">
25+
<component :is="section.component" />
26+
</section>
27+
</template>
28+
29+
<!-- No results -->
30+
<div v-if="filteredSections.length === 0" class="text-center py-16">
31+
<p class="text-sm text-arena-400">No settings match "<span class="text-arena-200">{{ search }}</span>"</p>
32+
<button @click="search = ''" class="mt-2 text-xs text-arena-500 hover:text-arena-300 transition-colors">Clear search</button>
33+
</div>
34+
</div>
35+
</template>
36+
37+
<script setup>
38+
import { ref, computed, watch, nextTick, onMounted, onUnmounted, markRaw } from 'vue'
39+
import Icon from '../../components/Icon.vue'
40+
import BackupSection from './BackupSection.vue'
41+
42+
const search = ref('')
43+
const sectionRefs = ref({})
44+
45+
const sections = [
46+
{
47+
id: 'backup',
48+
keywords: ['backup', 'restore', 'export', 'import', 'download', 'upload', 'data', 'archive', 'tar'],
49+
component: markRaw(BackupSection),
50+
},
51+
]
52+
53+
const filteredSections = computed(() => {
54+
const q = search.value.toLowerCase().trim()
55+
if (!q) return sections
56+
return sections.filter(s =>
57+
s.keywords.some(k => k.includes(q)) || s.id.includes(q)
58+
)
59+
})
60+
61+
watch(filteredSections, async (visible) => {
62+
if (search.value && visible.length === 1) {
63+
await nextTick()
64+
sectionRefs.value[visible[0].id]?.scrollIntoView({ behavior: 'smooth', block: 'start' })
65+
}
66+
})
67+
68+
function onKeydown(e) {
69+
if (e.key === '/' && !e.metaKey && !e.ctrlKey && !e.altKey) {
70+
const tag = e.target.tagName
71+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return
72+
e.preventDefault()
73+
document.querySelector('[placeholder="Search settings..."]')?.focus()
74+
}
75+
}
76+
77+
onMounted(() => document.addEventListener('keydown', onKeydown))
78+
onUnmounted(() => document.removeEventListener('keydown', onKeydown))
79+
</script>

0 commit comments

Comments
 (0)