feat: static sites feature with Gitea/GitHub/GitLab support and Deno backend

Deploy static content from Git repository folders with optional server-side
API endpoints. Supports Gitea/Forgejo/Gogs, GitHub, and GitLab with provider
autodetection.

- New Sites entity with CRUD, encrypted secrets, and manual/push/tag sync triggers
- Pluggable GitProvider interface with three implementations
- Deno container mode: auto-generates router from API_{method}_{name} exports
- Static container mode: nginx serving files with optional markdown rendering
- Wizard UI with provider selector, repo picker, branch/folder tree pickers
- Deploy pipeline builds fresh image, starts container, configures NPM proxy
- Stop/Start buttons, force redeploy on manual trigger
- Periodic health checker detects crashed containers
- Proxy route existence check during auto-sync
This commit is contained in:
2026-04-11 03:35:57 +03:00
parent b0816502bf
commit 8d2c5a063b
31 changed files with 4967 additions and 5 deletions
+106
View File
@@ -604,4 +604,110 @@ export function fetchContainerStats(
);
}
// ── Static Sites ──────────────────────────────────────────────────────
import type { StaticSite, StaticSiteSecret, FolderEntry, GitProvider, RepoInfo } from './types';
export function listStaticSites(): Promise<StaticSite[]> {
return get<StaticSite[]>('/api/sites');
}
export function getStaticSite(id: string): Promise<StaticSite> {
return get<StaticSite>(`/api/sites/${id}`);
}
export function createStaticSite(data: Partial<StaticSite>): Promise<StaticSite> {
return post<StaticSite>('/api/sites', data);
}
export function updateStaticSite(id: string, data: Partial<StaticSite>): Promise<StaticSite> {
return put<StaticSite>(`/api/sites/${id}`, data);
}
export function deleteStaticSite(id: string): Promise<{ deleted: string }> {
return del<{ deleted: string }>(`/api/sites/${id}`);
}
export function deployStaticSite(id: string): Promise<{ status: string }> {
return post<{ status: string }>(`/api/sites/${id}/deploy`);
}
export function stopStaticSite(id: string): Promise<{ status: string }> {
return post<{ status: string }>(`/api/sites/${id}/stop`);
}
export function startStaticSite(id: string): Promise<{ status: string }> {
return post<{ status: string }>(`/api/sites/${id}/start`);
}
export function listStaticSiteRepos(data: {
provider?: string;
gitea_url: string;
access_token?: string;
query?: string;
}): Promise<RepoInfo[]> {
return post<RepoInfo[]>('/api/sites/repos', data);
}
export function detectStaticSiteProvider(url: string): Promise<{ provider: GitProvider }> {
return post<{ provider: GitProvider }>('/api/sites/detect-provider', { url });
}
export function testStaticSiteConnection(data: {
provider?: string;
gitea_url: string;
access_token?: string;
repo_owner: string;
repo_name: string;
}): Promise<{ status: string }> {
return post<{ status: string }>('/api/sites/test-connection', data);
}
export function listStaticSiteBranches(data: {
provider?: string;
gitea_url: string;
access_token?: string;
repo_owner: string;
repo_name: string;
}): Promise<string[]> {
return post<string[]>('/api/sites/branches', data);
}
export function listStaticSiteTree(data: {
provider?: string;
gitea_url: string;
access_token?: string;
repo_owner: string;
repo_name: string;
branch: string;
}): Promise<FolderEntry[]> {
return post<FolderEntry[]>('/api/sites/tree', data);
}
export function listStaticSiteSecrets(siteId: string): Promise<StaticSiteSecret[]> {
return get<StaticSiteSecret[]>(`/api/sites/${siteId}/secrets`);
}
export function createStaticSiteSecret(
siteId: string,
data: { key: string; value: string; encrypted?: boolean }
): Promise<StaticSiteSecret> {
return post<StaticSiteSecret>(`/api/sites/${siteId}/secrets`, data);
}
export function updateStaticSiteSecret(
siteId: string,
secretId: string,
data: { key?: string; value?: string; encrypted?: boolean }
): Promise<StaticSiteSecret> {
return put<StaticSiteSecret>(`/api/sites/${siteId}/secrets/${secretId}`, data);
}
export function deleteStaticSiteSecret(
siteId: string,
secretId: string
): Promise<{ deleted: string }> {
return del<{ deleted: string }>(`/api/sites/${siteId}/secrets/${secretId}`);
}
export { ApiError };
+78 -2
View File
@@ -17,7 +17,8 @@
"events": "Events",
"settings": "Settings",
"logout": "Log out",
"dns": "DNS Records"
"dns": "DNS Records",
"sites": "Sites"
},
"dashboard": {
"title": "Dashboard",
@@ -539,6 +540,77 @@
},
"lastChecked": "Last checked"
},
"sites": {
"title": "Static Sites",
"addSite": "New Site",
"newSite": "New Static Site",
"createSite": "Create Site",
"noSites": "No static sites",
"noSitesDesc": "Deploy static content from a Git repository folder.",
"searchPlaceholder": "Search sites by name, domain, or repo...",
"noMatching": "No sites match your search.",
"name": "Name",
"domain": "Domain",
"mode": "Mode",
"status": "Status",
"lastSync": "Last Sync",
"deploy": "Deploy",
"stop": "Stop",
"start": "Start",
"openSite": "Open Site",
"confirmDelete": "Delete Site",
"confirmDeleteMsg": "This will permanently delete the site and remove its container",
"siteInfo": "Site Information",
"folder": "Folder",
"syncTrigger": "Sync Trigger",
"commitSha": "Commit SHA",
"secrets": "Secrets",
"addSecret": "Add Secret",
"noSecrets": "No secrets configured. Add secrets if your site needs server-side API keys.",
"secretKey": "Key",
"secretValue": "Value",
"encryptSecret": "Encrypt value",
"saveSecret": "Add Secret",
"step1Title": "1. Repository",
"step2Title": "2. Select Branch",
"step3Title": "3. Select Folder",
"step4Title": "4. Configuration",
"step5Title": "5. Review & Create",
"fullRepoUrl": "Repository URL",
"fullRepoUrlHelp": "Paste a full URL to auto-fill the fields below (e.g., https://git.example.com/owner/repo)",
"serverUrl": "Server URL",
"repoUrl": "Git Server URL",
"repoUrlHelp": "Paste a full repo URL or enter the server base URL (Gitea, Forgejo, Gogs)",
"repoOwner": "Owner",
"repoName": "Repository",
"accessToken": "Access Token",
"accessTokenPlaceholder": "Optional — for private repos",
"accessTokenHelp": "Personal access token with repo read permissions. Leave empty for public repos.",
"noToken": "None (public repo)",
"testConnection": "Test Connection",
"connectionSuccess": "Repository is accessible",
"loadingBranches": "Loading branches...",
"selectBranch": "Select a branch",
"chooseBranch": "Choose a branch...",
"branch": "Branch",
"loadingTree": "Loading repository tree...",
"selectFolder": "Select the folder containing your site files",
"selectedFolder": "Selected folder",
"siteName": "Site Name",
"domainHelp": "Public domain for the site. Proxy will be configured automatically.",
"modeStaticDesc": "HTML, CSS, JS, images served via Nginx",
"modeDenoDesc": "Static files + server-side API from api/ folder",
"triggerManual": "Manual",
"triggerPush": "On Push",
"triggerTag": "On Tag",
"tagPattern": "Tag Pattern",
"tagPatternHelp": "Glob pattern for matching tags (e.g., v*, pages-*)",
"renderMarkdown": "Render Markdown files to HTML",
"provider": "Git Provider",
"detectedProvider": "Auto-detected",
"browseRepos": "Browse repositories",
"selectRepo": "Select a repository"
},
"common": {
"cancel": "Cancel",
"confirm": "Confirm",
@@ -556,7 +628,11 @@
"restart": "Restart",
"remove": "Remove",
"instance": "instance",
"instances": "instances"
"instances": "instances",
"next": "Next",
"yes": "Yes",
"no": "No",
"saving": "Saving..."
},
"instance": {
"stopConfirm": "This will stop the running container. The instance can be started again later.",
+78 -2
View File
@@ -17,7 +17,8 @@
"events": "События",
"settings": "Настройки",
"logout": "Выйти",
"dns": "DNS-записи"
"dns": "DNS-записи",
"sites": "Сайты"
},
"dashboard": {
"title": "Панель управления",
@@ -539,6 +540,77 @@
},
"lastChecked": "Последняя проверка"
},
"sites": {
"title": "Статические сайты",
"addSite": "Новый сайт",
"newSite": "Новый статический сайт",
"createSite": "Создать сайт",
"noSites": "Нет статических сайтов",
"noSitesDesc": "Разверните статический контент из папки Git-репозитория.",
"searchPlaceholder": "Поиск по имени, домену или репозиторию...",
"noMatching": "Нет сайтов, соответствующих поиску.",
"name": "Имя",
"domain": "Домен",
"mode": "Режим",
"status": "Статус",
"lastSync": "Последняя синхр.",
"deploy": "Развернуть",
"stop": "Остановить",
"start": "Запустить",
"openSite": "Открыть сайт",
"confirmDelete": "Удалить сайт",
"confirmDeleteMsg": "Это удалит сайт и его контейнер",
"siteInfo": "Информация о сайте",
"folder": "Папка",
"syncTrigger": "Триггер синхр.",
"commitSha": "Коммит SHA",
"secrets": "Секреты",
"addSecret": "Добавить секрет",
"noSecrets": "Секреты не настроены. Добавьте их, если сайту нужны серверные API-ключи.",
"secretKey": "Ключ",
"secretValue": "Значение",
"encryptSecret": "Шифровать значение",
"saveSecret": "Добавить секрет",
"step1Title": "1. Репозиторий",
"step2Title": "2. Выбор ветки",
"step3Title": "3. Выбор папки",
"step4Title": "4. Настройки",
"step5Title": "5. Проверка и создание",
"fullRepoUrl": "URL репозитория",
"fullRepoUrlHelp": "Вставьте полный URL для автозаполнения полей ниже (напр., https://git.example.com/owner/repo)",
"serverUrl": "URL сервера",
"repoUrl": "URL Git-сервера",
"repoUrlHelp": "Вставьте полный URL репозитория или базовый URL сервера (Gitea, Forgejo, Gogs)",
"repoOwner": "Владелец",
"repoName": "Репозиторий",
"accessToken": "Токен доступа",
"accessTokenPlaceholder": "Необязательно — для приватных репозиториев",
"accessTokenHelp": "Персональный токен с правами на чтение репозитория. Оставьте пустым для публичных.",
"noToken": "Нет (публичный репо)",
"testConnection": "Проверить соединение",
"connectionSuccess": "Репозиторий доступен",
"loadingBranches": "Загрузка веток...",
"selectBranch": "Выберите ветку",
"chooseBranch": "Выберите ветку...",
"branch": "Ветка",
"loadingTree": "Загрузка дерева репозитория...",
"selectFolder": "Выберите папку с файлами сайта",
"selectedFolder": "Выбранная папка",
"siteName": "Имя сайта",
"domainHelp": "Публичный домен сайта. Прокси будет настроен автоматически.",
"modeStaticDesc": "HTML, CSS, JS, изображения через Nginx",
"modeDenoDesc": "Статические файлы + серверный API из папки api/",
"triggerManual": "Вручную",
"triggerPush": "При пуше",
"triggerTag": "По тегу",
"tagPattern": "Паттерн тега",
"tagPatternHelp": "Glob-паттерн для тегов (напр., v*, pages-*)",
"renderMarkdown": "Рендерить Markdown-файлы в HTML",
"provider": "Git-провайдер",
"detectedProvider": "Автоопределён",
"browseRepos": "Обзор репозиториев",
"selectRepo": "Выберите репозиторий"
},
"common": {
"cancel": "Отмена",
"confirm": "Подтвердить",
@@ -556,7 +628,11 @@
"restart": "Перезапустить",
"remove": "Удалить",
"instance": "экземпляр",
"instances": "экземпляров"
"instances": "экземпляров",
"next": "Далее",
"yes": "Да",
"no": "Нет",
"saving": "Сохранение..."
},
"instance": {
"stopConfirm": "Контейнер будет остановлен. Экземпляр можно будет запустить снова позже.",
+57
View File
@@ -336,6 +336,63 @@ export interface StaleContainer {
days_stale: number;
}
/** A static site deployed from a Git repository folder. */
export interface StaticSite {
id: string;
name: string;
provider: GitProvider;
gitea_url: string;
repo_owner: string;
repo_name: string;
branch: string;
folder_path: string;
access_token: string;
domain: string;
mode: 'static' | 'deno';
render_markdown: boolean;
sync_trigger: 'push' | 'tag' | 'manual';
tag_pattern: string;
container_id: string;
proxy_route_id: string;
status: StaticSiteStatus;
last_sync_at: string;
last_commit_sha: string;
error: string;
created_at: string;
updated_at: string;
}
export type StaticSiteStatus = 'idle' | 'syncing' | 'deployed' | 'failed' | 'stopped';
export type GitProvider = '' | 'gitea' | 'github' | 'gitlab';
/** An encrypted environment variable for a static site's Deno backend. */
export interface StaticSiteSecret {
id: string;
site_id: string;
key: string;
value: string;
encrypted: boolean;
created_at: string;
updated_at: string;
}
/** A repository from the Git provider's API. */
export interface RepoInfo {
owner: string;
name: string;
full_name: string;
description: string;
private: boolean;
html_url: string;
}
/** A folder entry from the Gitea repo tree. */
export interface FolderEntry {
path: string;
is_dir: boolean;
}
/** Container CPU and memory stats from the Docker stats API. */
export interface ContainerStats {
cpu_percent: number;