feat: persistent storage for Deno static sites
Build / build (push) Successful in 10m21s

- Add storage_enabled and storage_limit_mb columns to static_sites.
- Create/attach Docker volumes (tinyforge-site-{name}-data) for Deno
  sites with storage enabled, mounted at /app/data.
- Grant --allow-write=/app/data in Deno container CMD.
- Add storage usage API endpoint (GET /api/sites/{id}/storage).
- Show storage section in site detail page with usage bar.
- Add storage toggle and limit field to new site wizard.
- Use ConfirmDialog for secret deletion instead of inline delete.
This commit is contained in:
2026-04-13 00:12:51 +03:00
parent 9ec25a8d5a
commit b622384774
12 changed files with 327 additions and 15 deletions
+8
View File
@@ -358,10 +358,18 @@ export interface StaticSite {
last_sync_at: string;
last_commit_sha: string;
error: string;
storage_enabled: boolean;
storage_limit_mb: number;
created_at: string;
updated_at: string;
}
export interface StaticSiteStorageUsage {
enabled: boolean;
used_bytes: number;
limit_mb: number;
}
export type StaticSiteStatus = 'idle' | 'syncing' | 'deployed' | 'failed' | 'stopped';
export type GitProvider = '' | 'gitea' | 'github' | 'gitlab';
+67 -5
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import type { StaticSite, StaticSiteSecret } from '$lib/types';
import type { StaticSite, StaticSiteSecret, StaticSiteStorageUsage } from '$lib/types';
import * as api from '$lib/api';
import { t } from '$lib/i18n';
import { page } from '$app/stores';
@@ -14,6 +14,7 @@
let error = $state('');
let deploying = $state(false);
let confirmDelete = $state(false);
let confirmDeleteSecretId = $state<string | null>(null);
// Secret form.
let showSecretForm = $state(false);
@@ -21,6 +22,7 @@
let secretValue = $state('');
let secretEncrypted = $state(true);
let secretSubmitting = $state(false);
let storageUsage = $state<StaticSiteStorageUsage | null>(null);
const siteId = $derived($page.params.id);
@@ -30,6 +32,9 @@
try {
site = await api.getStaticSite(siteId!);
secrets = await api.listStaticSiteSecrets(siteId!);
if (site.storage_enabled) {
storageUsage = await api.getStaticSiteStorage(siteId!);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load site';
} finally {
@@ -102,13 +107,15 @@
}
}
async function handleDeleteSecret(secretId: string) {
if (!site) return;
async function handleDeleteSecret() {
if (!site || !confirmDeleteSecretId) return;
try {
await api.deleteStaticSiteSecret(site.id, secretId);
await api.deleteStaticSiteSecret(site.id, confirmDeleteSecretId);
secrets = await api.listStaticSiteSecrets(site.id);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete secret';
} finally {
confirmDeleteSecretId = null;
}
}
@@ -237,6 +244,11 @@
<span class="text-[var(--text-tertiary)]">{$t('sites.commitSha')}</span>
<span class="text-[var(--text-primary)] font-mono text-xs">{site.last_commit_sha ? site.last_commit_sha.slice(0, 8) : '-'}</span>
{#if site.mode === 'deno' && site.storage_enabled}
<span class="text-[var(--text-tertiary)]">{$t('sites.dataPath')}</span>
<span class="font-mono text-xs text-[var(--text-primary)]">/app/data</span>
{/if}
</div>
{#if site.error}
@@ -296,7 +308,7 @@
</div>
<button
type="button"
onclick={() => handleDeleteSecret(secret.id)}
onclick={() => { confirmDeleteSecretId = secret.id; }}
class="rounded p-1 text-[var(--text-tertiary)] hover:text-[var(--color-danger)] transition-colors"
>
<IconTrash size={14} />
@@ -307,6 +319,45 @@
{/if}
</div>
</div>
<!-- Storage -->
{#if site.storage_enabled && site.mode === 'deno'}
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6">
<h2 class="text-base font-semibold text-[var(--text-primary)] mb-4">{$t('sites.storage')}</h2>
<div class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
<span class="text-[var(--text-tertiary)]">{$t('sites.storageVolume')}</span>
<span class="font-mono text-xs text-[var(--text-primary)]">tinyforge-site-{site.name}-data</span>
<span class="text-[var(--text-tertiary)]">{$t('sites.storageMountPath')}</span>
<span class="font-mono text-xs text-[var(--text-primary)]">/app/data</span>
<span class="text-[var(--text-tertiary)]">{$t('sites.storageLimit')}</span>
<span class="text-[var(--text-primary)]">{site.storage_limit_mb > 0 ? `${site.storage_limit_mb} MB` : $t('sites.unlimited')}</span>
<span class="text-[var(--text-tertiary)]">{$t('sites.storageUsed')}</span>
<span class="text-[var(--text-primary)]">
{#if storageUsage}
{storageUsage.used_bytes < 1024 ? `${storageUsage.used_bytes} B` : storageUsage.used_bytes < 1048576 ? `${(storageUsage.used_bytes / 1024).toFixed(1)} KB` : `${(storageUsage.used_bytes / 1048576).toFixed(1)} MB`}
{:else}
-
{/if}
</span>
</div>
{#if storageUsage && site.storage_limit_mb > 0}
{@const pct = Math.min(100, (storageUsage.used_bytes / (site.storage_limit_mb * 1048576)) * 100)}
<div class="mt-4">
<div class="h-2 rounded-full bg-[var(--surface-card-hover)] overflow-hidden">
<div
class="h-full rounded-full transition-all {pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-yellow-500' : 'bg-emerald-500'}"
style="width: {pct.toFixed(1)}%"
></div>
</div>
<p class="text-xs text-[var(--text-tertiary)] mt-1">{pct.toFixed(1)}% {$t('sites.storageOfLimit')}</p>
</div>
{/if}
</div>
{/if}
{/if}
</div>
@@ -320,3 +371,14 @@
oncancel={() => { confirmDelete = false; }}
/>
{/if}
{#if confirmDeleteSecretId}
<ConfirmDialog
open={!!confirmDeleteSecretId}
title={$t('sites.confirmDeleteSecret')}
message={`${$t('sites.confirmDeleteSecretMsg')} "${secrets.find(s => s.id === confirmDeleteSecretId)?.key}"?`}
confirmLabel={$t('common.delete')}
onconfirm={handleDeleteSecret}
oncancel={() => { confirmDeleteSecretId = null; }}
/>
{/if}
+31 -1
View File
@@ -60,6 +60,8 @@
let renderMarkdown = $state(false);
let syncTrigger = $state<'push' | 'tag' | 'manual'>('manual');
let tagPattern = $state('');
let storageEnabled = $state(false);
let storageLimitStr = $state('0');
// Step 5: Review + submit.
let submitting = $state(false);
@@ -254,7 +256,9 @@
mode,
render_markdown: renderMarkdown,
sync_trigger: syncTrigger,
tag_pattern: syncTrigger === 'tag' ? tagPattern : undefined
tag_pattern: syncTrigger === 'tag' ? tagPattern : undefined,
storage_enabled: storageEnabled,
storage_limit_mb: parseInt(storageLimitStr, 10) || 0
});
goto(`/sites/${site.id}`);
} catch (e) {
@@ -590,6 +594,27 @@
<input type="checkbox" bind:checked={renderMarkdown} class="rounded border-[var(--border-input)]" />
{$t('sites.renderMarkdown')}
</label>
<!-- Persistent Storage (Deno only) -->
{#if mode === 'deno'}
<label class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
<input type="checkbox" bind:checked={storageEnabled} class="rounded border-[var(--border-input)]" />
{$t('sites.enableStorage')}
</label>
{#if storageEnabled}
<div class="space-y-3 rounded-lg border border-[var(--border-secondary)] p-4">
<p class="text-xs text-[var(--text-tertiary)]">{$t('sites.storageHelp')}</p>
<FormField
label={$t('sites.storageLimitMB')}
name="storageLimitMB"
type="number"
bind:value={storageLimitStr}
placeholder="0"
helpText={$t('sites.storageLimitHelp')}
/>
</div>
{/if}
{/if}
</div>
<div class="mt-6 flex justify-between">
@@ -639,6 +664,11 @@
<span class="text-[var(--text-tertiary)]">{$t('sites.renderMarkdown')}</span>
<span class="text-[var(--text-primary)]">{renderMarkdown ? $t('common.yes') : $t('common.no')}</span>
{#if mode === 'deno'}
<span class="text-[var(--text-tertiary)]">{$t('sites.storage')}</span>
<span class="text-[var(--text-primary)]">{storageEnabled ? (parseInt(storageLimitStr, 10) > 0 ? `${storageLimitStr} MB` : $t('sites.unlimited')) : $t('common.no')}</span>
{/if}
<span class="text-[var(--text-tertiary)]">{$t('sites.accessToken')}</span>
<span class="text-[var(--text-primary)]">{accessToken ? '••••••••' : $t('sites.noToken')}</span>
</div>