feat(apps): per-app deploy/activity timeline
Every deploy across all four source kinds now writes a workload-scoped
event via a shared plugin.EmitDeployEvent helper (replacing the inline
emit duplicated in static/dockerfile, standardizing static's metadata
key site_id->workload_id, and adding emission to image+compose which
were silent). New indexed event_log.workload_id column, EventLogFilter
.WorkloadID, and GET /api/workloads/{id}/events (id pinned from path).
Frontend: a forge "Activity" panel on /apps/[id] reusing EventLogEntry,
live SSE prepend filtered by workload_id, load-more pagination, an
All/Errors severity filter, and a shared toEventLogEntry mapper. en/ru
i18n parity.
Security: compose's failure status emits a generic reason instead of raw
`docker compose up` output, which can echo app secrets and egresses to
operator webhooks (NotificationURL + event-trigger actions); full detail
stays only in the returned error. Rune-safe 256-rune status cap.
Reviewed: go + typescript APPROVE; security HIGH fixed.
This commit is contained in:
@@ -765,6 +765,19 @@ export function fetchEventLogStats(signal?: AbortSignal): Promise<EventLogStats>
|
||||
return get<EventLogStats>('/api/events/log/stats', signal);
|
||||
}
|
||||
|
||||
export function fetchWorkloadEvents(
|
||||
id: string,
|
||||
params?: { severity?: string; limit?: number; offset?: number },
|
||||
signal?: AbortSignal
|
||||
): Promise<EventLogEntry[]> {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.severity) query.set('severity', params.severity);
|
||||
if (params?.limit) query.set('limit', String(params.limit));
|
||||
if (params?.offset) query.set('offset', String(params.offset));
|
||||
const qs = query.toString();
|
||||
return get<EventLogEntry[]>(`/api/workloads/${id}/events${qs ? `?${qs}` : ''}`, signal);
|
||||
}
|
||||
|
||||
export function deleteEvent(id: number): Promise<{ status: string }> {
|
||||
return del<{ status: string }>(`/api/events/log/${id}`);
|
||||
}
|
||||
|
||||
@@ -545,6 +545,9 @@
|
||||
},
|
||||
"source": {
|
||||
"deploy": "Deploy",
|
||||
"image": "Image",
|
||||
"compose": "Compose",
|
||||
"dockerfile": "Dockerfile",
|
||||
"static_site": "Static Site",
|
||||
"stale_scanner": "Stale Scanner",
|
||||
"stale_cleanup": "Stale Cleanup",
|
||||
@@ -1406,6 +1409,16 @@
|
||||
"deployError": "Deploy failed",
|
||||
"saveError": "Save failed",
|
||||
"deleteError": "Delete failed",
|
||||
"activity": {
|
||||
"title": "Activity",
|
||||
"subtitle": "Recent deploys and events for this app",
|
||||
"empty": "No activity yet. Deploys and events will appear here.",
|
||||
"recentNote": "Showing recent activity.",
|
||||
"loadMore": "Load more",
|
||||
"filterAll": "All",
|
||||
"filterErrors": "Errors",
|
||||
"noErrors": "No errors in the loaded activity."
|
||||
},
|
||||
"runtimeState": {
|
||||
"title": "Sync status",
|
||||
"sub": "Last successful sync of the source repo and the current container state.",
|
||||
|
||||
@@ -545,6 +545,9 @@
|
||||
},
|
||||
"source": {
|
||||
"deploy": "Развёртывание",
|
||||
"image": "Образ",
|
||||
"compose": "Compose",
|
||||
"dockerfile": "Dockerfile",
|
||||
"static_site": "Статический сайт",
|
||||
"stale_scanner": "Сканер устаревших",
|
||||
"stale_cleanup": "Очистка устаревших",
|
||||
@@ -1406,6 +1409,16 @@
|
||||
"deployError": "Деплой не удался",
|
||||
"saveError": "Сохранение не удалось",
|
||||
"deleteError": "Удаление не удалось",
|
||||
"activity": {
|
||||
"title": "Активность",
|
||||
"subtitle": "Недавние деплои и события этого приложения",
|
||||
"empty": "Пока нет активности. Деплои и события появятся здесь.",
|
||||
"recentNote": "Показана недавняя активность.",
|
||||
"loadMore": "Загрузить ещё",
|
||||
"filterAll": "Все",
|
||||
"filterErrors": "Ошибки",
|
||||
"noErrors": "Нет ошибок в загруженной активности."
|
||||
},
|
||||
"runtimeState": {
|
||||
"title": "Статус синхронизации",
|
||||
"sub": "Последняя успешная синхронизация репозитория и текущее состояние контейнера.",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { getAuthToken } from './auth';
|
||||
import type { EventLogEntry } from '$lib/types';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -41,12 +42,32 @@ export interface DeployStatusPayload {
|
||||
export interface EventLogSSEPayload {
|
||||
id: number;
|
||||
source: string;
|
||||
/**
|
||||
* Owning workload id, or "" for global events (stale scanner, admin).
|
||||
* Mirrors the Go EventLogPayload.WorkloadID json tag. EventLog frames are
|
||||
* broadcast to ALL connections, so per-workload views must filter on this.
|
||||
*/
|
||||
workload_id: string;
|
||||
severity: string;
|
||||
message: string;
|
||||
metadata: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** Map an SSE event_log frame to the REST EventLogEntry shape. Shared by the
|
||||
* global events page and the per-app activity panel so the mapping (incl. the
|
||||
* severity narrowing) lives in one place. */
|
||||
export function toEventLogEntry(payload: EventLogSSEPayload): EventLogEntry {
|
||||
return {
|
||||
id: payload.id,
|
||||
source: payload.source,
|
||||
severity: payload.severity as EventLogEntry['severity'],
|
||||
message: payload.message,
|
||||
metadata: payload.metadata,
|
||||
created_at: payload.created_at
|
||||
};
|
||||
}
|
||||
|
||||
export interface BuildLogPayload {
|
||||
workload_id: string;
|
||||
line: string;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { onDestroy } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import type { Container, PluginWorkloadInput, Workload } from '$lib/types';
|
||||
import type { Container, EventLogEntry, PluginWorkloadInput, Workload } from '$lib/types';
|
||||
import type { RedeployTrigger, WorkloadTriggerBinding } from '$lib/api';
|
||||
import * as api from '$lib/api';
|
||||
import {
|
||||
@@ -26,6 +26,7 @@
|
||||
} from '$lib/components/icons';
|
||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import EventLogEntryComponent from '$lib/components/EventLogEntry.svelte';
|
||||
import ContainerLogs from '$lib/components/ContainerLogs.svelte';
|
||||
import ContainerStats from '$lib/components/ContainerStats.svelte';
|
||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||
@@ -62,7 +63,7 @@
|
||||
import { t } from '$lib/i18n';
|
||||
import { fmt } from '$lib/format/datetime';
|
||||
import { formatBytes } from '$lib/format/bytes';
|
||||
import { connectGlobalEvents, type SSEConnection } from '$lib/sse';
|
||||
import { connectGlobalEvents, toEventLogEntry, type SSEConnection } from '$lib/sse';
|
||||
|
||||
// Route params come back as `string | undefined`; the route file
|
||||
// guarantees `id` exists, but the empty-string fallback satisfies
|
||||
@@ -452,6 +453,43 @@
|
||||
previewMeta = next;
|
||||
}
|
||||
|
||||
// Fire-and-forget load of the most recent activity for this workload.
|
||||
// Non-fatal on failure: the panel just shows its empty state.
|
||||
async function loadActivity(): Promise<void> {
|
||||
activityLoading = true;
|
||||
try {
|
||||
activityEvents = await api.fetchWorkloadEvents(id, { limit: ACTIVITY_PAGE });
|
||||
activityOffset = activityEvents.length;
|
||||
activityHasMore = activityEvents.length === ACTIVITY_PAGE;
|
||||
} catch {
|
||||
// Non-fatal: panel shows empty state.
|
||||
} finally {
|
||||
activityLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Page in older events below the live-prepended head. The global events
|
||||
// page can't filter by workload, so this is the only per-app history view.
|
||||
async function loadMoreActivity(): Promise<void> {
|
||||
if (activityLoadingMore || !activityHasMore) return;
|
||||
activityLoadingMore = true;
|
||||
try {
|
||||
const more = await api.fetchWorkloadEvents(id, { limit: ACTIVITY_PAGE, offset: activityOffset });
|
||||
// Dedup by id: a live SSE prepend can shift the offset window by one,
|
||||
// so a page boundary may re-return an already-shown row. {#each (entry.id)}
|
||||
// REQUIRES unique keys, so drop duplicates.
|
||||
const seen = new Set(activityEvents.map((e) => e.id));
|
||||
const fresh = more.filter((e) => !seen.has(e.id));
|
||||
activityEvents = [...activityEvents, ...fresh];
|
||||
activityOffset += more.length;
|
||||
activityHasMore = more.length === ACTIVITY_PAGE;
|
||||
} catch {
|
||||
// Non-fatal; leave the list as-is.
|
||||
} finally {
|
||||
activityLoadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function doTeardownPreview(): Promise<void> {
|
||||
if (!confirmTeardownId || tearingDown) return;
|
||||
const cid = confirmTeardownId;
|
||||
@@ -499,6 +537,25 @@
|
||||
let stopping = $state(false);
|
||||
let starting = $state(false);
|
||||
|
||||
// ── Activity timeline (per-app deploy/event feed) ───────
|
||||
// Read-only panel: most-recent events for this workload, kept live by
|
||||
// the global SSE stream. activityNewIds drives the brief fade-in on
|
||||
// freshly-arrived rows, mirroring the global events page.
|
||||
const ACTIVITY_PAGE = 25;
|
||||
let activityEvents = $state<EventLogEntry[]>([]);
|
||||
let activityLoading = $state(true);
|
||||
let activityNewIds = $state<Set<number>>(new Set());
|
||||
let activityOffset = $state(0);
|
||||
let activityHasMore = $state(false);
|
||||
let activityLoadingMore = $state(false);
|
||||
// Client-side severity filter over the already-loaded rows (no refetch).
|
||||
let activitySeverity = $state<'all' | 'error'>('all');
|
||||
const visibleActivity = $derived(
|
||||
activitySeverity === 'all'
|
||||
? activityEvents
|
||||
: activityEvents.filter((e) => e.severity === activitySeverity)
|
||||
);
|
||||
|
||||
// Sequence tokens + abort controllers so a slow in-flight probe
|
||||
// cannot overwrite a faster newer one's result, and so an in-flight
|
||||
// request is cancelled when the page unmounts (the user navigates
|
||||
@@ -884,6 +941,10 @@
|
||||
// each preview child's slug-prefixed URL from its full record.
|
||||
void loadPreviewMeta();
|
||||
|
||||
// Fire-and-forget activity-timeline load. Failure is swallowed
|
||||
// inside loadActivity; the panel falls back to its empty state.
|
||||
void loadActivity();
|
||||
|
||||
// Fire-and-forget runtime / storage probes for static workloads.
|
||||
// Failure is captured into their dedicated *_error fields and
|
||||
// must not break the rest of the detail page render.
|
||||
@@ -1402,6 +1463,25 @@
|
||||
if (!currentId) return;
|
||||
const conn = connectGlobalEvents({
|
||||
buildLogWorkloadId: currentId,
|
||||
onEventLog: (payload) => {
|
||||
// EventLog frames broadcast to EVERY connection (only high-volume
|
||||
// build logs are workload-filtered server-side), so scope to this
|
||||
// app client-side before prepending to the activity timeline.
|
||||
if (payload.workload_id !== currentId) return;
|
||||
const entry = toEventLogEntry(payload);
|
||||
// Skip rows already shown (e.g. one that load-more just paged in)
|
||||
// — {#each (entry.id)} requires unique keys.
|
||||
if (activityEvents.some((e) => e.id === entry.id)) return;
|
||||
// Bound generously so a live prepend can't truncate paged-in
|
||||
// history (load-more grows the list intentionally).
|
||||
activityEvents = [entry, ...activityEvents].slice(0, 500);
|
||||
// Prune highlight ids to rows still present after the cap.
|
||||
const present = new Set(activityEvents.map((e) => e.id));
|
||||
activityNewIds = new Set([...activityNewIds, entry.id].filter((x) => present.has(x)));
|
||||
setTimeout(() => {
|
||||
activityNewIds = new Set([...activityNewIds].filter((x) => x !== entry.id));
|
||||
}, 3000);
|
||||
},
|
||||
onBuildLog: (payload) => {
|
||||
// Server already filters by workload_id; this is belt-and-braces.
|
||||
if (payload.workload_id !== currentId) return;
|
||||
@@ -2606,6 +2686,80 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ── Activity timeline (recent deploys + events) ──
|
||||
Read-only feed of the most recent workload-scoped events, kept
|
||||
live by the global SSE stream's onEventLog callback. -->
|
||||
{#if !editing}
|
||||
<section class="panel" aria-labelledby="activity-heading">
|
||||
<header class="panel-head">
|
||||
<h2 class="panel-title" id="activity-heading">
|
||||
{$t('apps.detail.activity.title')}<span class="title-accent">.</span>
|
||||
</h2>
|
||||
<span class="panel-sub">{$t('apps.detail.activity.subtitle')}</span>
|
||||
{#if activityEvents.length > 0}
|
||||
<div class="ml-auto inline-flex items-center rounded-lg bg-[var(--surface-card-hover)] p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={activitySeverity === 'all'}
|
||||
class="rounded-md px-2.5 py-1 text-xs font-medium transition-all duration-150
|
||||
{activitySeverity === 'all'
|
||||
? 'bg-[var(--surface-card)] text-[var(--text-primary)] shadow-[var(--shadow-sm)]'
|
||||
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'}"
|
||||
onclick={() => { activitySeverity = 'all'; }}
|
||||
>
|
||||
{$t('apps.detail.activity.filterAll')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={activitySeverity === 'error'}
|
||||
class="rounded-md px-2.5 py-1 text-xs font-medium transition-all duration-150
|
||||
{activitySeverity === 'error'
|
||||
? 'bg-[var(--surface-card)] text-[var(--text-primary)] shadow-[var(--shadow-sm)]'
|
||||
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'}"
|
||||
onclick={() => { activitySeverity = 'error'; }}
|
||||
>
|
||||
{$t('apps.detail.activity.filterErrors')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if activityLoading}
|
||||
<div class="flex items-center justify-center py-16">
|
||||
<IconLoader size={20} class="animate-spin text-[var(--color-brand-500)]" />
|
||||
</div>
|
||||
{:else if activityEvents.length === 0}
|
||||
<p class="hint">{$t('apps.detail.activity.empty')}</p>
|
||||
{:else if visibleActivity.length === 0}
|
||||
<p class="hint">{$t('apps.detail.activity.noErrors')}</p>
|
||||
{:else}
|
||||
<div
|
||||
class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] divide-y divide-[var(--border-secondary)]"
|
||||
>
|
||||
{#each visibleActivity as entry (entry.id)}
|
||||
<EventLogEntryComponent {entry} isNew={activityNewIds.has(entry.id)} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !activityLoading && activityHasMore}
|
||||
<div class="flex justify-center pt-2 pb-1">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] px-4 py-2 text-sm font-medium text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)] disabled:opacity-50"
|
||||
onclick={loadMoreActivity}
|
||||
disabled={activityLoadingMore}
|
||||
>
|
||||
{#if activityLoadingMore}
|
||||
<IconLoader size={16} class="animate-spin" />
|
||||
{/if}
|
||||
{$t('apps.detail.activity.loadMore')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ── Per-workload notification routes ───────────── -->
|
||||
{#if !editing}
|
||||
<WorkloadNotificationsPanel workloadId={id} />
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
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 { connectGlobalEvents, toEventLogEntry, type SSEConnection, 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';
|
||||
@@ -146,14 +146,7 @@
|
||||
// ── SSE real-time events ─────────────────────────────────────
|
||||
|
||||
function handleSSEEvent(payload: EventLogSSEPayload): void {
|
||||
const newEntry: EventLogEntry = {
|
||||
id: payload.id,
|
||||
source: payload.source,
|
||||
severity: payload.severity as EventLogEntry['severity'],
|
||||
message: payload.message,
|
||||
metadata: payload.metadata,
|
||||
created_at: payload.created_at
|
||||
};
|
||||
const newEntry = toEventLogEntry(payload);
|
||||
|
||||
// Update stats.
|
||||
stats = {
|
||||
|
||||
Reference in New Issue
Block a user