- 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:
@@ -255,6 +255,7 @@ func (s *Server) Router() chi.Router {
|
||||
r.Route("/sites/{id}", func(r chi.Router) {
|
||||
r.Get("/", s.getStaticSite)
|
||||
r.Get("/secrets", s.listStaticSiteSecrets)
|
||||
r.Get("/storage", s.getStaticSiteStorage)
|
||||
|
||||
// Admin-only mutations.
|
||||
r.Group(func(r chi.Router) {
|
||||
|
||||
@@ -61,6 +61,8 @@ type createStaticSiteRequest struct {
|
||||
RenderMarkdown bool `json:"render_markdown"`
|
||||
SyncTrigger string `json:"sync_trigger"`
|
||||
TagPattern string `json:"tag_pattern"`
|
||||
StorageEnabled bool `json:"storage_enabled"`
|
||||
StorageLimitMB int `json:"storage_limit_mb"`
|
||||
}
|
||||
|
||||
func (s *Server) createStaticSite(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -109,6 +111,8 @@ func (s *Server) createStaticSite(w http.ResponseWriter, r *http.Request) {
|
||||
RenderMarkdown: req.RenderMarkdown,
|
||||
SyncTrigger: req.SyncTrigger,
|
||||
TagPattern: req.TagPattern,
|
||||
StorageEnabled: req.StorageEnabled,
|
||||
StorageLimitMB: req.StorageLimitMB,
|
||||
Status: "idle",
|
||||
}
|
||||
|
||||
@@ -174,6 +178,8 @@ func (s *Server) updateStaticSite(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
existing.RenderMarkdown = req.RenderMarkdown
|
||||
existing.TagPattern = req.TagPattern
|
||||
existing.StorageEnabled = req.StorageEnabled
|
||||
existing.StorageLimitMB = req.StorageLimitMB
|
||||
|
||||
// Update access token only if a new one is provided.
|
||||
if req.AccessToken != "" {
|
||||
@@ -604,6 +610,42 @@ func (s *Server) detectStaticSiteProvider(w http.ResponseWriter, r *http.Request
|
||||
respondJSON(w, http.StatusOK, map[string]string{"provider": provider})
|
||||
}
|
||||
|
||||
// ── Storage Usage ──────────────────────────────────────────────────
|
||||
|
||||
func (s *Server) getStaticSiteStorage(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
site, err := s.store.GetStaticSiteByID(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "static site")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to get static site")
|
||||
return
|
||||
}
|
||||
|
||||
if !site.StorageEnabled {
|
||||
respondJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"enabled": false,
|
||||
"used_bytes": 0,
|
||||
"limit_mb": 0,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
usage, err := s.docker.InspectSiteStorageUsage(r.Context(), site.ContainerID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to inspect storage usage")
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"enabled": true,
|
||||
"used_bytes": usage.UsedBytes,
|
||||
"limit_mb": site.StorageLimitMB,
|
||||
})
|
||||
}
|
||||
|
||||
// maskToken returns a masked version of a token string for API responses.
|
||||
func maskToken(token string) string {
|
||||
if token == "" {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/moby/moby/client"
|
||||
)
|
||||
|
||||
// ExecInContainer runs a command inside a running container and returns the combined output.
|
||||
func (c *Client) ExecInContainer(ctx context.Context, containerID string, cmd []string) (string, error) {
|
||||
exec, err := c.api.ExecCreate(ctx, containerID, client.ExecCreateOptions{
|
||||
Cmd: cmd,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("exec create: %w", err)
|
||||
}
|
||||
|
||||
attach, err := c.api.ExecAttach(ctx, exec.ID, client.ExecAttachOptions{})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("exec attach: %w", err)
|
||||
}
|
||||
defer attach.Close()
|
||||
|
||||
var buf bytes.Buffer
|
||||
if _, err := io.Copy(&buf, attach.Reader); err != nil {
|
||||
return "", fmt.Errorf("exec read output: %w", err)
|
||||
}
|
||||
|
||||
// Check exit code.
|
||||
inspect, err := c.api.ExecInspect(ctx, exec.ID, client.ExecInspectOptions{})
|
||||
if err != nil {
|
||||
return buf.String(), nil // best effort — return output even if inspect fails
|
||||
}
|
||||
if inspect.ExitCode != 0 {
|
||||
return "", fmt.Errorf("exec exited with code %d", inspect.ExitCode)
|
||||
}
|
||||
|
||||
return buf.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package docker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/moby/moby/client"
|
||||
)
|
||||
|
||||
// SiteVolumeName returns the deterministic volume name for a static site.
|
||||
func SiteVolumeName(siteName string) string {
|
||||
return fmt.Sprintf("tinyforge-site-%s-data", siteName)
|
||||
}
|
||||
|
||||
// EnsureSiteVolume creates a named volume for a static site if it doesn't already exist.
|
||||
// Returns the volume name.
|
||||
func (c *Client) EnsureSiteVolume(ctx context.Context, siteName string) (string, error) {
|
||||
name := SiteVolumeName(siteName)
|
||||
|
||||
// Check if volume already exists.
|
||||
_, err := c.api.VolumeInspect(ctx, name, client.VolumeInspectOptions{})
|
||||
if err == nil {
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// Create the volume.
|
||||
_, err = c.api.VolumeCreate(ctx, client.VolumeCreateOptions{
|
||||
Name: name,
|
||||
Labels: map[string]string{
|
||||
"tinyforge.static-site": "true",
|
||||
"tinyforge.site-name": siteName,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create volume %s: %w", name, err)
|
||||
}
|
||||
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// RemoveSiteVolume removes the named volume for a static site.
|
||||
func (c *Client) RemoveSiteVolume(ctx context.Context, siteName string) error {
|
||||
name := SiteVolumeName(siteName)
|
||||
_, err := c.api.VolumeRemove(ctx, name, client.VolumeRemoveOptions{Force: true})
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove volume %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StorageUsage holds the disk usage info for a site volume.
|
||||
type StorageUsage struct {
|
||||
UsedBytes int64 `json:"used_bytes"`
|
||||
}
|
||||
|
||||
// InspectSiteStorageUsage execs `du -sb /app/data` inside the container to get usage.
|
||||
// Returns 0 if the container is not running or the path doesn't exist.
|
||||
func (c *Client) InspectSiteStorageUsage(ctx context.Context, containerID string) (StorageUsage, error) {
|
||||
if containerID == "" {
|
||||
return StorageUsage{}, nil
|
||||
}
|
||||
|
||||
output, err := c.ExecInContainer(ctx, containerID, []string{"du", "-sb", "/app/data"})
|
||||
if err != nil {
|
||||
// Container not running or path doesn't exist — return zero.
|
||||
return StorageUsage{}, nil
|
||||
}
|
||||
|
||||
// du output: "12345\t/app/data\n"
|
||||
parts := strings.Fields(strings.TrimSpace(output))
|
||||
if len(parts) == 0 {
|
||||
return StorageUsage{}, nil
|
||||
}
|
||||
|
||||
bytes, err := strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil {
|
||||
return StorageUsage{}, nil
|
||||
}
|
||||
|
||||
return StorageUsage{UsedBytes: bytes}, nil
|
||||
}
|
||||
@@ -231,15 +231,19 @@ COPY public/ /app/public/
|
||||
COPY api/ /app/api/
|
||||
COPY router.ts /app/router.ts
|
||||
|
||||
# Create data directory for persistent storage (mounted as volume when enabled).
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# Cache dependencies.
|
||||
RUN deno install --entrypoint router.ts
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["deno", "run", "--allow-net", "--allow-read", "--allow-env", "router.ts"]
|
||||
CMD ["deno", "run", "--allow-net", "--allow-read", "--allow-write=/app/data", "--allow-env", "router.ts"]
|
||||
`
|
||||
}
|
||||
|
||||
|
||||
// GenerateStaticDockerfile generates a minimal nginx Dockerfile for pure static sites.
|
||||
func GenerateStaticDockerfile() string {
|
||||
return `FROM nginx:alpine
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/moby/moby/api/types/mount"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/crypto"
|
||||
"github.com/alexei/tinyforge/internal/docker"
|
||||
"github.com/alexei/tinyforge/internal/events"
|
||||
@@ -198,6 +200,22 @@ func (m *Manager) Deploy(ctx context.Context, siteID string, force bool) error {
|
||||
|
||||
containerName := fmt.Sprintf("dw-site-%s", site.Name)
|
||||
|
||||
// Prepare volume mounts for persistent storage.
|
||||
var mounts []mount.Mount
|
||||
if site.StorageEnabled && mode == "deno" {
|
||||
volName, volErr := m.docker.EnsureSiteVolume(ctx, site.Name)
|
||||
if volErr != nil {
|
||||
slog.Warn("static site: failed to ensure storage volume", "site", site.Name, "error", volErr)
|
||||
} else {
|
||||
mounts = append(mounts, mount.Mount{
|
||||
Type: mount.TypeVolume,
|
||||
Source: volName,
|
||||
Target: "/app/data",
|
||||
})
|
||||
slog.Info("static site: storage volume attached", "site", site.Name, "volume", volName)
|
||||
}
|
||||
}
|
||||
|
||||
// Create and start new container.
|
||||
containerID, err := m.docker.CreateContainer(ctx, docker.ContainerConfig{
|
||||
Name: containerName,
|
||||
@@ -206,6 +224,7 @@ func (m *Manager) Deploy(ctx context.Context, siteID string, force bool) error {
|
||||
ExposedPorts: []string{containerPort + "/tcp"},
|
||||
NetworkName: networkName,
|
||||
NetworkID: networkID,
|
||||
Mounts: mounts,
|
||||
Labels: map[string]string{
|
||||
"tinyforge.static-site": site.ID,
|
||||
"tinyforge.static-site-name": site.Name,
|
||||
@@ -229,6 +248,7 @@ func (m *Manager) Deploy(ctx context.Context, siteID string, force bool) error {
|
||||
ExposedPorts: []string{containerPort + "/tcp"},
|
||||
NetworkName: networkName,
|
||||
NetworkID: networkID,
|
||||
Mounts: mounts,
|
||||
Labels: map[string]string{
|
||||
"tinyforge.static-site": site.ID,
|
||||
"tinyforge.static-site-name": site.Name,
|
||||
@@ -346,6 +366,13 @@ func (m *Manager) Remove(ctx context.Context, siteID string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Remove storage volume if it was enabled (best effort).
|
||||
if site.StorageEnabled {
|
||||
if err := m.docker.RemoveSiteVolume(ctx, site.Name); err != nil {
|
||||
slog.Warn("static site: failed to remove storage volume", "site", site.Name, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -217,6 +217,8 @@ type StaticSite struct {
|
||||
LastSyncAt string `json:"last_sync_at"`
|
||||
LastCommitSHA string `json:"last_commit_sha"`
|
||||
Error string `json:"error"`
|
||||
StorageEnabled bool `json:"storage_enabled"`
|
||||
StorageLimitMB int `json:"storage_limit_mb"` // 0 = unlimited
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
const staticSiteCols = `id, name, provider, gitea_url, repo_owner, repo_name, branch, folder_path,
|
||||
access_token, domain, mode, render_markdown, sync_trigger, tag_pattern,
|
||||
container_id, proxy_route_id, status, last_sync_at, last_commit_sha, error,
|
||||
created_at, updated_at`
|
||||
storage_enabled, storage_limit_mb, created_at, updated_at`
|
||||
|
||||
// CreateStaticSite inserts a new static site and returns it.
|
||||
func (s *Store) CreateStaticSite(site StaticSite) (StaticSite, error) {
|
||||
@@ -22,12 +22,13 @@ func (s *Store) CreateStaticSite(site StaticSite) (StaticSite, error) {
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO static_sites (`+staticSiteCols+`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
site.ID, site.Name, site.Provider, site.GiteaURL, site.RepoOwner, site.RepoName,
|
||||
site.Branch, site.FolderPath, site.AccessToken, site.Domain, site.Mode,
|
||||
BoolToInt(site.RenderMarkdown), site.SyncTrigger, site.TagPattern,
|
||||
site.ContainerID, site.ProxyRouteID, site.Status, site.LastSyncAt,
|
||||
site.LastCommitSHA, site.Error, site.CreatedAt, site.UpdatedAt,
|
||||
site.LastCommitSHA, site.Error, BoolToInt(site.StorageEnabled), site.StorageLimitMB,
|
||||
site.CreatedAt, site.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return StaticSite{}, fmt.Errorf("insert static site: %w", err)
|
||||
@@ -100,11 +101,12 @@ func (s *Store) UpdateStaticSite(site StaticSite) error {
|
||||
result, err := s.db.Exec(
|
||||
`UPDATE static_sites SET name=?, provider=?, gitea_url=?, repo_owner=?, repo_name=?, branch=?,
|
||||
folder_path=?, access_token=?, domain=?, mode=?, render_markdown=?,
|
||||
sync_trigger=?, tag_pattern=?, updated_at=?
|
||||
sync_trigger=?, tag_pattern=?, storage_enabled=?, storage_limit_mb=?, updated_at=?
|
||||
WHERE id=?`,
|
||||
site.Name, site.Provider, site.GiteaURL, site.RepoOwner, site.RepoName, site.Branch,
|
||||
site.FolderPath, site.AccessToken, site.Domain, site.Mode,
|
||||
BoolToInt(site.RenderMarkdown), site.SyncTrigger, site.TagPattern,
|
||||
BoolToInt(site.StorageEnabled), site.StorageLimitMB,
|
||||
site.UpdatedAt, site.ID,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -168,35 +170,39 @@ func (s *Store) DeleteStaticSite(id string) error {
|
||||
// scanStaticSiteRow scans a static site from a *sql.Row.
|
||||
func scanStaticSiteRow(row *sql.Row) (StaticSite, error) {
|
||||
var site StaticSite
|
||||
var renderMarkdown int
|
||||
var renderMarkdown, storageEnabled int
|
||||
err := row.Scan(
|
||||
&site.ID, &site.Name, &site.Provider, &site.GiteaURL, &site.RepoOwner, &site.RepoName,
|
||||
&site.Branch, &site.FolderPath, &site.AccessToken, &site.Domain, &site.Mode,
|
||||
&renderMarkdown, &site.SyncTrigger, &site.TagPattern,
|
||||
&site.ContainerID, &site.ProxyRouteID, &site.Status, &site.LastSyncAt,
|
||||
&site.LastCommitSHA, &site.Error, &site.CreatedAt, &site.UpdatedAt,
|
||||
&site.LastCommitSHA, &site.Error, &storageEnabled, &site.StorageLimitMB,
|
||||
&site.CreatedAt, &site.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return StaticSite{}, err
|
||||
}
|
||||
site.RenderMarkdown = renderMarkdown != 0
|
||||
site.StorageEnabled = storageEnabled != 0
|
||||
return site, nil
|
||||
}
|
||||
|
||||
// scanStaticSiteRows scans a static site from a *sql.Rows cursor.
|
||||
func scanStaticSiteRows(rows *sql.Rows) (StaticSite, error) {
|
||||
var site StaticSite
|
||||
var renderMarkdown int
|
||||
var renderMarkdown, storageEnabled int
|
||||
err := rows.Scan(
|
||||
&site.ID, &site.Name, &site.Provider, &site.GiteaURL, &site.RepoOwner, &site.RepoName,
|
||||
&site.Branch, &site.FolderPath, &site.AccessToken, &site.Domain, &site.Mode,
|
||||
&renderMarkdown, &site.SyncTrigger, &site.TagPattern,
|
||||
&site.ContainerID, &site.ProxyRouteID, &site.Status, &site.LastSyncAt,
|
||||
&site.LastCommitSHA, &site.Error, &site.CreatedAt, &site.UpdatedAt,
|
||||
&site.LastCommitSHA, &site.Error, &storageEnabled, &site.StorageLimitMB,
|
||||
&site.CreatedAt, &site.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return StaticSite{}, fmt.Errorf("scan static site: %w", err)
|
||||
}
|
||||
site.RenderMarkdown = renderMarkdown != 0
|
||||
site.StorageEnabled = storageEnabled != 0
|
||||
return site, nil
|
||||
}
|
||||
|
||||
@@ -125,6 +125,9 @@ func (s *Store) runMigrations() error {
|
||||
`ALTER TABLE settings ADD COLUMN image_prune_threshold_mb INTEGER NOT NULL DEFAULT 1024`,
|
||||
// Add provider column to static_sites (2026-04-11).
|
||||
`ALTER TABLE static_sites ADD COLUMN provider TEXT NOT NULL DEFAULT ''`,
|
||||
// Add persistent storage columns to static_sites (2026-04-12).
|
||||
`ALTER TABLE static_sites ADD COLUMN storage_enabled INTEGER NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE static_sites ADD COLUMN storage_limit_mb INTEGER NOT NULL DEFAULT 0`,
|
||||
}
|
||||
|
||||
for _, m := range migrations {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user