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;
|
||||
|
||||
Reference in New Issue
Block a user