feat: rename Docker Watcher to Tinyforge
Build / build (push) Successful in 12m20s

Rebrand the project as Tinyforge to reflect its evolution from a Docker
container watcher into a self-hosted mini CI/deployment platform.

Rename covers: Go module path, Docker labels, DB/config filenames,
JWT issuer, Dockerfile binary, docker-compose, CI workflows, frontend
i18n, README with static sites docs, and all code comments.
This commit is contained in:
2026-04-12 21:30:23 +03:00
parent 8d2c5a063b
commit 791cd4d6af
68 changed files with 512 additions and 224 deletions
+31 -7
View File
@@ -25,6 +25,29 @@
let logContainer: HTMLDivElement | undefined = $state();
let eventSource: EventSource | null = null;
// Batch incoming SSE log lines to avoid per-line re-renders.
let pendingLines: string[] = [];
let flushTimer: ReturnType<typeof setTimeout> | null = null;
function flushPendingLines() {
flushTimer = null;
if (pendingLines.length === 0) return;
let updated = [...lines, ...pendingLines];
pendingLines = [];
if (updated.length > tailCount * 2) {
updated = updated.slice(-tailCount);
}
lines = updated;
scrollToBottom();
}
function enqueueLine(line: string) {
pendingLines.push(line);
if (!flushTimer) {
flushTimer = setTimeout(flushPendingLines, 150);
}
}
async function loadLogs() {
loading = true;
error = '';
@@ -49,12 +72,7 @@
try {
const data = JSON.parse(e.data);
if (data.line) {
lines = [...lines, data.line];
// Trim to max lines.
if (lines.length > tailCount * 2) {
lines = lines.slice(-tailCount);
}
scrollToBottom();
enqueueLine(data.line);
}
} catch { /* ignore parse errors */ }
};
@@ -69,6 +87,9 @@
eventSource.close();
eventSource = null;
}
// Flush any buffered lines before stopping.
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
flushPendingLines();
following = false;
}
@@ -90,7 +111,10 @@
// Load on mount.
$effect(() => { loadLogs(); });
onDestroy(() => { stopFollowing(); });
onDestroy(() => {
stopFollowing();
if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; }
});
</script>
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-page)] shadow-lg">
+7 -2
View File
@@ -19,8 +19,11 @@
$effect(() => {
let cancelled = false;
let inflight = false;
async function load() {
if (inflight) return; // Skip if previous request still pending.
inflight = true;
try {
const result = await api.fetchContainerStats(projectId, stageId, instanceId);
if (!cancelled) {
@@ -31,13 +34,15 @@
if (!cancelled) {
error = true;
}
} finally {
inflight = false;
}
}
load();
// Poll every 10 seconds.
const interval = setInterval(load, 10_000);
// Poll every 30 seconds (reduced from 10s to limit concurrent connections).
const interval = setInterval(load, 30_000);
return () => {
cancelled = true;
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Lucide-based SVG icon components for Docker Watcher.
* Lucide-based SVG icon components for Tinyforge.
* Task 2: Inline SVGs from Lucide icon set as Svelte components.
*
* Each icon is a standalone .svelte component accepting size and class props.
+3 -3
View File
@@ -1,6 +1,6 @@
{
"app": {
"name": "Docker Watcher",
"name": "Tinyforge",
"version": "v0.1"
},
"health": {
@@ -508,7 +508,7 @@
"password": "Password"
},
"login": {
"title": "Docker Watcher",
"title": "Tinyforge",
"subtitle": "Sign in to your account",
"username": "Username",
"password": "Password",
@@ -819,7 +819,7 @@
},
"dns": {
"title": "DNS Records",
"description": "View and manage DNS records created by Docker Watcher.",
"description": "View and manage DNS records created by Tinyforge.",
"wildcardActive": "Wildcard DNS Mode Active",
"wildcardActiveDesc": "DNS records are managed externally via wildcard DNS. Disable wildcard DNS in Settings to manage records individually.",
"refresh": "Refresh",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"app": {
"name": "Docker Watcher",
"name": "Tinyforge",
"version": "v0.1"
},
"health": {
@@ -508,7 +508,7 @@
"password": "Пароль"
},
"login": {
"title": "Docker Watcher",
"title": "Tinyforge",
"subtitle": "Войдите в свой аккаунт",
"username": "Имя пользователя",
"password": "Пароль",
@@ -819,7 +819,7 @@
},
"dns": {
"title": "DNS-записи",
"description": "Просмотр и управление DNS-записями, созданными Docker Watcher.",
"description": "Просмотр и управление DNS-записями, созданными Tinyforge.",
"wildcardActive": "Режим Wildcard DNS активен",
"wildcardActiveDesc": "DNS-записи управляются внешне через wildcard DNS. Отключите wildcard DNS в настройках для индивидуального управления записями.",
"refresh": "Обновить",
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* SSE client helper with auto-reconnect and exponential backoff.
*
* Provides type-safe event handling for Docker Watcher's real-time
* Provides type-safe event handling for Tinyforge's real-time
* event streams (deploy logs and instance status changes).
*/
+23
View File
@@ -0,0 +1,23 @@
/**
* Simple pub/sub bus for SSE event_log payloads.
*
* The layout component publishes events from the single global SSE connection.
* Pages (e.g. /events) subscribe without opening a duplicate SSE connection.
*/
import type { EventLogSSEPayload } from '$lib/sse';
type Listener = (payload: EventLogSSEPayload) => void;
const listeners = new Set<Listener>();
export function subscribeEventLog(fn: Listener): () => void {
listeners.add(fn);
return () => { listeners.delete(fn); };
}
export function publishEventLog(payload: EventLogSSEPayload): void {
for (const fn of listeners) {
fn(payload);
}
}
+8 -8
View File
@@ -8,7 +8,8 @@
import { fetchEventLog, fetchEventLogStats, clearAllEvents, deleteEvent } from '$lib/api';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import { toasts } from '$lib/stores/toast';
import { connectGlobalEvents, type SSEConnection, type EventLogSSEPayload } from '$lib/sse';
import { subscribeEventLog } from '$lib/stores/event-log-bus';
import type { EventLogSSEPayload } from '$lib/sse';
import type { EventLogEntry, EventLogStats } from '$lib/types';
import EventLogEntryComponent from '$lib/components/EventLogEntry.svelte';
import EventLogFilter from '$lib/components/EventLogFilter.svelte';
@@ -35,7 +36,7 @@
const PAGE_SIZE = 50;
let offset = $state(0);
let sseConnection: SSEConnection | null = null;
let unsubscribeEventLog: (() => void) | null = null;
let listEl: HTMLDivElement | undefined = $state();
let showClearConfirm = $state(false);
@@ -198,16 +199,15 @@
loadEvents();
loadStats();
sseConnection = connectGlobalEvents({
onEventLog(payload) {
handleSSEEvent(payload);
}
// Subscribe to event_log events from the global SSE connection (no duplicate connection).
unsubscribeEventLog = subscribeEventLog((payload: EventLogSSEPayload) => {
handleSSEEvent(payload);
});
});
onDestroy(() => {
sseConnection?.close();
sseConnection = null;
unsubscribeEventLog?.();
unsubscribeEventLog = null;
});
</script>
+16 -15
View File
@@ -161,7 +161,7 @@
let showDeleteConfirm = $state(false);
const projectId = $derived($page.params.id);
const projectId = $derived($page.params.id!); // always present on [id] route
async function loadProject() {
if (!project) loading = true;
@@ -188,21 +188,22 @@
}
instancesByStage = mapped;
try {
const allDeploys = await api.listDeploys(20);
deploys = allDeploys.filter((d) => d.project_id === projectId);
} catch {
deploys = [];
}
// Fetch deploys, settings, and images in parallel (independent of each other).
const [deploysResult, settingsResult, imagesResult] = await Promise.allSettled([
api.listDeploys(20),
api.getSettings(),
api.listProjectImages(projectId)
]);
try {
const settings = await api.getSettings();
settingsDomain = settings.domain ?? '';
} catch { /* non-critical */ }
try {
localImages = await api.listProjectImages(projectId);
} catch { localImages = []; }
deploys = deploysResult.status === 'fulfilled'
? deploysResult.value.filter((d) => d.project_id === projectId)
: [];
settingsDomain = settingsResult.status === 'fulfilled'
? (settingsResult.value.domain ?? '')
: settingsDomain;
localImages = imagesResult.status === 'fulfilled'
? imagesResult.value
: [];
} catch (e) {
error = e instanceof Error ? e.message : $t('projectDetail.loadFailed');
} finally {
+1 -1
View File
@@ -145,7 +145,7 @@
<h3 class="text-base font-semibold text-[var(--text-primary)]">{$t('settingsAuth.oidcConfig')}</h3>
<div class="mt-4 space-y-4">
{#each [
{ id: 'issuer', label: $t('settingsAuth.issuerUrl'), type: 'url', key: 'oidc_issuer_url', placeholder: 'https://auth.example.com/application/o/docker-watcher/' },
{ id: 'issuer', label: $t('settingsAuth.issuerUrl'), type: 'url', key: 'oidc_issuer_url', placeholder: 'https://auth.example.com/application/o/tinyforge/' },
{ id: 'client_id', label: $t('settingsAuth.clientId'), type: 'text', key: 'oidc_client_id', placeholder: '' },
{ id: 'client_secret', label: $t('settingsAuth.clientSecret'), type: 'password', key: 'oidc_client_secret', placeholder: '' },
{ id: 'redirect', label: $t('settingsAuth.redirectUrl'), type: 'url', key: 'oidc_redirect_url', placeholder: 'https://watcher.example.com/api/auth/oidc/callback' }