Skip to content

Commit bc920c2

Browse files
authored
Merge pull request #13 from n3-rd/version-control
feat: add version management for PocketBase instances
2 parents f26843d + d10eac3 commit bc920c2

File tree

8 files changed

+695
-22
lines changed

8 files changed

+695
-22
lines changed

Dockerfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ RUN mkdir -p /var/multipb/data \
5151
COPY core/cli/*.sh /usr/local/bin/
5252
RUN chmod +x /usr/local/bin/*.sh
5353

54+
# Create versions directory
55+
RUN mkdir -p /var/multipb/versions
56+
5457
# Copy entrypoint
5558
COPY core/entrypoint.sh /entrypoint.sh
5659
RUN chmod +x /entrypoint.sh

apps/dashboard/src/routes/+page.svelte

Lines changed: 87 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
healthy?: boolean;
99
size?: string;
1010
created?: string;
11+
version?: string;
1112
}
1213
1314
interface Credentials {
@@ -33,6 +34,11 @@
3334
let customPassword = '';
3435
let portError = '';
3536
let portChecking = false;
37+
let selectedVersion = '';
38+
let availableVersions: string[] = [];
39+
let installedVersions: string[] = [];
40+
let latestVersion = '';
41+
let loadingVersions = false;
3642
3743
const API_BASE = '/api';
3844
@@ -61,6 +67,39 @@
6167
}
6268
}
6369
70+
async function fetchVersions() {
71+
loadingVersions = true;
72+
try {
73+
// Get latest version
74+
const latestRes = await fetch(`${API_BASE}/versions/latest`);
75+
if (latestRes.ok) {
76+
const data = await latestRes.json();
77+
latestVersion = data.version || '';
78+
if (!selectedVersion) {
79+
selectedVersion = latestVersion;
80+
}
81+
}
82+
83+
// Get installed versions
84+
const installedRes = await fetch(`${API_BASE}/versions/installed`);
85+
if (installedRes.ok) {
86+
const data = await installedRes.json();
87+
installedVersions = data.versions || [];
88+
}
89+
90+
// Get available versions (limited list)
91+
const availableRes = await fetch(`${API_BASE}/versions/available`);
92+
if (availableRes.ok) {
93+
const data = await availableRes.json();
94+
availableVersions = data.versions || [];
95+
}
96+
} catch (e) {
97+
console.error('Failed to fetch versions:', e);
98+
} finally {
99+
loadingVersions = false;
100+
}
101+
}
102+
64103
async function checkPort(port: string) {
65104
if (!port) {
66105
portError = '';
@@ -104,7 +143,8 @@
104143
name: newInstanceName.trim(),
105144
port: customPort ? parseInt(customPort, 10) : undefined,
106145
email: customEmail ? customEmail.trim() : undefined,
107-
password: customPassword || undefined
146+
password: customPassword || undefined,
147+
version: selectedVersion || undefined
108148
};
109149
110150
const res = await fetch(`${API_BASE}/instances`, {
@@ -161,6 +201,7 @@
161201
onMount(() => {
162202
fetchInstances();
163203
fetchStats();
204+
fetchVersions();
164205
const interval = setInterval(() => {
165206
fetchInstances();
166207
fetchStats();
@@ -190,6 +231,10 @@
190231
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
191232
Dashboard
192233
</button>
234+
<a href="https://pocketbase.io/docs" target="_blank" rel="noopener" class="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-gray-500 hover:bg-gray-800/50 hover:text-gray-300 font-medium text-sm transition-all">
235+
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><path d="M14 2v6h6M16 13H8M16 17H8M10 9H8"/></svg>
236+
PocketBase Docs
237+
</a>
193238
</nav>
194239

195240
<div class="mt-auto pt-4 border-t border-gray-800/50">
@@ -210,9 +255,15 @@
210255
<h1 class="text-2xl font-bold text-white mb-1">Dashboard</h1>
211256
<p class="text-gray-500 text-sm">Manage your PocketBase instances</p>
212257
</div>
213-
<button on:click={() => showAddModal = true} class="bg-emerald-500 hover:bg-emerald-600 text-black px-5 py-2.5 rounded-lg font-semibold text-sm transition-all">
214-
+ Create Instance
215-
</button>
258+
<div class="flex items-center gap-3">
259+
<a href="https://pocketbase.io/docs" target="_blank" rel="noopener" class="px-4 py-2.5 border border-gray-700 hover:bg-gray-800/50 text-gray-300 rounded-lg font-medium text-sm transition-all flex items-center gap-2">
260+
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/></svg>
261+
Search Docs
262+
</a>
263+
<button on:click={() => showAddModal = true} class="bg-emerald-500 hover:bg-emerald-600 text-black px-5 py-2.5 rounded-lg font-semibold text-sm transition-all">
264+
+ Create Instance
265+
</button>
266+
</div>
216267
</header>
217268

218269
{#if error}
@@ -279,6 +330,7 @@
279330
<th class="px-6 py-4">Instance</th>
280331
<th class="px-6 py-4">Status</th>
281332
<th class="px-6 py-4">Port</th>
333+
<th class="px-6 py-4">Version</th>
282334
<th class="px-6 py-4">Size</th>
283335
{#if instances.some(i => i.created)}
284336
<th class="px-6 py-4">Created</th>
@@ -304,6 +356,11 @@
304356
</span>
305357
</td>
306358
<td class="px-6 py-4 text-sm text-gray-400 font-mono">{instance.port}</td>
359+
<td class="px-6 py-4">
360+
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium bg-gray-700/50 text-gray-400">
361+
{instance.version || 'unknown'}
362+
</span>
363+
</td>
307364
<td class="px-6 py-4 text-sm text-gray-400">{instance.size || '-'}</td>
308365
{#if instances.some(i => i.created)}
309366
<td class="px-6 py-4 text-sm text-gray-500">
@@ -416,6 +473,32 @@
416473
/>
417474
<p class="text-xs text-gray-600 mt-1">Min 8 characters recommended</p>
418475
</div>
476+
477+
<div class="pl-4">
478+
<label for="version-select" class="block text-xs font-medium text-gray-500 uppercase mb-2">PocketBase Version</label>
479+
{#if loadingVersions}
480+
<div class="w-full bg-[#0a0a0a] border border-gray-800 rounded-lg px-4 py-2.5 text-gray-500 text-sm">
481+
Loading versions...
482+
</div>
483+
{:else}
484+
<select
485+
id="version-select"
486+
bind:value={selectedVersion}
487+
class="w-full bg-[#0a0a0a] border border-gray-800 rounded-lg px-4 py-2.5 focus:outline-none focus:border-emerald-500/50 transition-all text-white text-sm"
488+
>
489+
{#if latestVersion}
490+
<option value={latestVersion}>{latestVersion} (latest)</option>
491+
{/if}
492+
{#each installedVersions.filter(v => v !== latestVersion) as version}
493+
<option value={version}>{version}</option>
494+
{/each}
495+
{#each availableVersions.slice(0, 10).filter(v => !installedVersions.includes(v) && v !== latestVersion) as version}
496+
<option value={version}>{version} (download)</option>
497+
{/each}
498+
</select>
499+
<p class="text-xs text-gray-600 mt-1">Version will be downloaded if not installed</p>
500+
{/if}
501+
</div>
419502
</div>
420503
{/if}
421504
</div>

apps/dashboard/src/routes/[name]/+page.svelte

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<script>
1+
<script lang="ts">
22
import { onMount } from 'svelte';
33
import { page } from '$app/stores';
44
import LogsPanel from './LogsPanel.svelte';
@@ -14,6 +14,15 @@
1414
let creatingBackup = false;
1515
let restoringBackup = null;
1616
17+
// Version management
18+
let showVersionModal = false;
19+
let availableVersions: string[] = [];
20+
let installedVersions: string[] = [];
21+
let latestVersion = '';
22+
let selectedUpgradeVersion = '';
23+
let upgrading = false;
24+
let loadingVersions = false;
25+
1726
$: instanceName = $page.params.name;
1827
const API_BASE = '/api';
1928
@@ -84,6 +93,69 @@
8493
}
8594
}
8695
96+
async function fetchVersions() {
97+
loadingVersions = true;
98+
try {
99+
const latestRes = await fetch(`${API_BASE}/versions/latest`);
100+
if (latestRes.ok) {
101+
const data = await latestRes.json();
102+
latestVersion = data.version || '';
103+
}
104+
105+
const installedRes = await fetch(`${API_BASE}/versions/installed`);
106+
if (installedRes.ok) {
107+
const data = await installedRes.json();
108+
installedVersions = data.versions || [];
109+
}
110+
111+
const availableRes = await fetch(`${API_BASE}/versions/available`);
112+
if (availableRes.ok) {
113+
const data = await availableRes.json();
114+
availableVersions = data.versions || [];
115+
}
116+
117+
// Set default selected version to current or latest
118+
selectedUpgradeVersion = instance?.version || latestVersion;
119+
} catch (e) {
120+
console.error('Failed to fetch versions:', e);
121+
} finally {
122+
loadingVersions = false;
123+
}
124+
}
125+
126+
async function upgradeInstance() {
127+
if (!selectedUpgradeVersion) return;
128+
if (selectedUpgradeVersion === instance?.version) {
129+
showVersionModal = false;
130+
return;
131+
}
132+
133+
if (!confirm(`Upgrade instance to v${selectedUpgradeVersion}? The instance will be stopped, upgraded, and restarted.`)) return;
134+
135+
upgrading = true;
136+
try {
137+
const res = await fetch(`${API_BASE}/instances/${instanceName}/upgrade`, {
138+
method: 'POST',
139+
headers: { 'Content-Type': 'application/json' },
140+
body: JSON.stringify({ version: selectedUpgradeVersion })
141+
});
142+
const result = await res.json();
143+
if (!res.ok) throw new Error(result.error || 'Failed to upgrade');
144+
await fetchInstance();
145+
showVersionModal = false;
146+
} catch (e) {
147+
error = e.message;
148+
} finally {
149+
upgrading = false;
150+
}
151+
}
152+
153+
function openVersionModal() {
154+
selectedUpgradeVersion = instance?.version || latestVersion;
155+
fetchVersions();
156+
showVersionModal = true;
157+
}
158+
87159
onMount(() => {
88160
fetchInstance();
89161
});
@@ -232,6 +304,15 @@
232304
<dt class="text-gray-500">Port</dt>
233305
<dd class="text-white font-mono">{instance.port}</dd>
234306
</div>
307+
<div class="flex justify-between py-2 border-b border-gray-800/50">
308+
<dt class="text-gray-500">Version</dt>
309+
<dd class="flex items-center gap-2">
310+
<span class="text-white font-mono">{instance.version || 'unknown'}</span>
311+
<button on:click={openVersionModal} class="text-xs px-2 py-1 bg-blue-500/10 hover:bg-blue-500/20 text-blue-400 rounded transition-all">
312+
Change
313+
</button>
314+
</dd>
315+
</div>
235316
<div class="flex justify-between py-2 border-b border-gray-800/50">
236317
<dt class="text-gray-500">Created</dt>
237318
<dd class="text-white">{formatDate(instance.created)}</dd>
@@ -308,6 +389,58 @@
308389
</div>
309390
</div>
310391

392+
<!-- Version Upgrade Modal -->
393+
{#if showVersionModal}
394+
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
395+
<div class="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center z-50 p-4" on:click|self={() => showVersionModal = false}>
396+
<div class="bg-[#111] rounded-2xl p-6 w-full max-w-md border border-gray-800">
397+
<h2 class="text-xl font-bold text-white mb-5">Change PocketBase Version</h2>
398+
399+
<div class="mb-4">
400+
<p class="text-sm text-gray-400 mb-2">Current version: <span class="text-white font-mono">{instance?.version || 'unknown'}</span></p>
401+
{#if latestVersion}
402+
<p class="text-xs text-gray-500">Latest version: <span class="text-emerald-400 font-mono">{latestVersion}</span></p>
403+
{/if}
404+
</div>
405+
406+
<div class="mb-6">
407+
<label for="upgrade-version" class="block text-xs font-medium text-gray-500 uppercase mb-2">Select Version</label>
408+
{#if loadingVersions}
409+
<div class="w-full bg-[#0a0a0a] border border-gray-800 rounded-lg px-4 py-2.5 text-gray-500 text-sm">
410+
Loading versions...
411+
</div>
412+
{:else}
413+
<select
414+
id="upgrade-version"
415+
bind:value={selectedUpgradeVersion}
416+
class="w-full bg-[#0a0a0a] border border-gray-800 rounded-lg px-4 py-2.5 focus:outline-none focus:border-emerald-500/50 transition-all text-white text-sm"
417+
>
418+
{#if latestVersion}
419+
<option value={latestVersion}>{latestVersion} (latest)</option>
420+
{/if}
421+
{#each installedVersions.filter(v => v !== latestVersion) as version}
422+
<option value={version}>{version}</option>
423+
{/each}
424+
{#each availableVersions.slice(0, 15).filter(v => !installedVersions.includes(v) && v !== latestVersion) as version}
425+
<option value={version}>{version} (download)</option>
426+
{/each}
427+
</select>
428+
<p class="text-xs text-gray-600 mt-1">Version will be downloaded if not installed</p>
429+
{/if}
430+
</div>
431+
432+
<div class="flex gap-3">
433+
<button on:click={() => showVersionModal = false} class="flex-1 px-4 py-2.5 border border-gray-700 rounded-lg font-medium text-gray-400 hover:bg-gray-800/50 transition-all text-sm">
434+
Cancel
435+
</button>
436+
<button on:click={upgradeInstance} disabled={upgrading || !selectedUpgradeVersion || selectedUpgradeVersion === instance?.version} class="flex-1 px-4 py-2.5 bg-emerald-500 hover:bg-emerald-600 text-black rounded-lg font-semibold transition-all text-sm disabled:opacity-50 disabled:cursor-not-allowed">
437+
{upgrading ? 'Upgrading...' : 'Upgrade'}
438+
</button>
439+
</div>
440+
</div>
441+
</div>
442+
{/if}
443+
311444
<style>
312445
:global(body) {
313446
background-color: #0a0a0a;

0 commit comments

Comments
 (0)