Skip to content

Commit 9ffb84d

Browse files
committed
Add events section
1 parent 5454f52 commit 9ffb84d

11 files changed

Lines changed: 565 additions & 11 deletions

File tree

README.md

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -63,15 +63,6 @@ cd dataflow-web/web && npm install && npm run dev
6363

6464
Open http://localhost:8080 (option 1) or http://localhost:5173 (option 2). Cluster access requires `KUBECONFIG` or in-cluster config.
6565

66-
## Deploying to cluster (Helm)
66+
## Deploying to cluster
6767

68-
With GUI enabled, set the dataflow-web image:
69-
70-
```bash
71-
helm upgrade --install dataflow-operator ./helm-charts/dataflow-operator \
72-
--set image.repository=... \
73-
--set image.tag=... \
74-
--set gui.enabled=true \
75-
--set gui.image.repository=dataflow-web \
76-
--set gui.image.tag=local
77-
```
68+
Deploy via Helm with `gui.enabled=true`. See [Web GUI documentation](../docs/docs/en/gui.md) for configuration and deployment details.

internal/gui/api_handler.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"fmt"
2323
"io"
2424
"net/http"
25+
"sort"
2526
"strconv"
2627
"strings"
2728

@@ -83,6 +84,8 @@ func (h *APIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
8384
h.handleStatus(w, r, filteredParts[1:])
8485
case filteredParts[0] == "namespaces":
8586
h.handleNamespaces(w, r)
87+
case filteredParts[0] == "events":
88+
h.handleEvents(w, r)
8689
default:
8790
http.Error(w, "Not found", http.StatusNotFound)
8891
}
@@ -421,6 +424,45 @@ func (h *APIHandler) handleStatus(w http.ResponseWriter, r *http.Request, parts
421424
json.NewEncoder(w).Encode(status)
422425
}
423426

427+
func (h *APIHandler) handleEvents(w http.ResponseWriter, r *http.Request) {
428+
if r.Method != "GET" {
429+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
430+
return
431+
}
432+
433+
namespace := r.URL.Query().Get("namespace")
434+
if namespace == "" {
435+
namespace = "default"
436+
}
437+
name := r.URL.Query().Get("name")
438+
439+
fieldSelector := "involvedObject.kind=DataFlow"
440+
if name != "" {
441+
fieldSelector += ",involvedObject.name=" + name
442+
}
443+
444+
events, err := h.server.k8sClient.CoreV1().Events(namespace).List(r.Context(), metav1.ListOptions{
445+
FieldSelector: fieldSelector,
446+
})
447+
if err != nil {
448+
h.server.logger.Error(err, "Failed to list events")
449+
http.Error(w, err.Error(), http.StatusInternalServerError)
450+
return
451+
}
452+
453+
items := events.Items
454+
sort.Slice(items, func(i, j int) bool {
455+
return items[j].LastTimestamp.Before(&items[i].LastTimestamp)
456+
})
457+
458+
const maxEvents = 100
459+
if len(items) > maxEvents {
460+
items = items[:maxEvents]
461+
}
462+
463+
json.NewEncoder(w).Encode(items)
464+
}
465+
424466
func (h *APIHandler) handleNamespaces(w http.ResponseWriter, r *http.Request) {
425467
if r.Method != "GET" {
426468
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)

internal/gui/api_handler_test.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,14 @@ import (
1919
"net/http/httptest"
2020
"strings"
2121
"testing"
22+
"time"
2223

24+
corev1 "k8s.io/api/core/v1"
2325
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2426
"k8s.io/apimachinery/pkg/runtime"
2527
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
2628
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
29+
k8sfake "k8s.io/client-go/kubernetes/fake"
2730
ctrl "sigs.k8s.io/controller-runtime"
2831
"sigs.k8s.io/controller-runtime/pkg/client"
2932
"sigs.k8s.io/controller-runtime/pkg/client/fake"
@@ -333,6 +336,119 @@ go_goroutines 5
333336
}
334337
}
335338

339+
func TestAPIHandler_Events_AllInNamespace(t *testing.T) {
340+
now := metav1.NewTime(time.Now())
341+
ev1 := &corev1.Event{
342+
ObjectMeta: metav1.ObjectMeta{Name: "ev1", Namespace: "default"},
343+
Type: "Normal",
344+
Reason: "ConfigMapCreated",
345+
Message: "Created ConfigMap test-cm",
346+
InvolvedObject: corev1.ObjectReference{
347+
Kind: "DataFlow",
348+
Name: "my-flow",
349+
Namespace: "default",
350+
},
351+
LastTimestamp: now,
352+
}
353+
fakeK8s := k8sfake.NewSimpleClientset(ev1)
354+
355+
scheme := runtime.NewScheme()
356+
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
357+
utilruntime.Must(dataflowv1.AddToScheme(scheme))
358+
fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build()
359+
360+
server := &Server{
361+
client: fakeClient,
362+
k8sClient: fakeK8s,
363+
logger: ctrl.Log.WithName("test"),
364+
}
365+
handler := NewAPIHandler(server)
366+
367+
req := httptest.NewRequest("GET", "/events?namespace=default", nil)
368+
w := httptest.NewRecorder()
369+
handler.ServeHTTP(w, req)
370+
371+
if w.Code != http.StatusOK {
372+
t.Errorf("Expected 200, got %d: %s", w.Code, w.Body.String())
373+
}
374+
375+
var events []corev1.Event
376+
if err := json.NewDecoder(w.Body).Decode(&events); err != nil {
377+
t.Fatalf("Failed to decode: %v", err)
378+
}
379+
if len(events) != 1 {
380+
t.Errorf("Expected 1 event, got %d", len(events))
381+
}
382+
if events[0].Reason != "ConfigMapCreated" {
383+
t.Errorf("Expected reason ConfigMapCreated, got %s", events[0].Reason)
384+
}
385+
}
386+
387+
func TestAPIHandler_Events_FilterByManifest(t *testing.T) {
388+
now := metav1.NewTime(time.Now())
389+
ev1 := &corev1.Event{
390+
ObjectMeta: metav1.ObjectMeta{Name: "ev1", Namespace: "default"},
391+
Type: "Normal",
392+
Reason: "ConfigMapCreated",
393+
Message: "Created ConfigMap",
394+
InvolvedObject: corev1.ObjectReference{
395+
Kind: "DataFlow", Name: "flow-a", Namespace: "default",
396+
},
397+
LastTimestamp: now,
398+
}
399+
ev2 := &corev1.Event{
400+
ObjectMeta: metav1.ObjectMeta{Name: "ev2", Namespace: "default"},
401+
Type: "Warning",
402+
Reason: "DeploymentFailed",
403+
Message: "Failed",
404+
InvolvedObject: corev1.ObjectReference{
405+
Kind: "DataFlow", Name: "flow-b", Namespace: "default",
406+
},
407+
LastTimestamp: now,
408+
}
409+
fakeK8s := k8sfake.NewSimpleClientset(ev1, ev2)
410+
411+
scheme := runtime.NewScheme()
412+
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
413+
utilruntime.Must(dataflowv1.AddToScheme(scheme))
414+
fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build()
415+
416+
server := &Server{
417+
client: fakeClient,
418+
k8sClient: fakeK8s,
419+
logger: ctrl.Log.WithName("test"),
420+
}
421+
handler := NewAPIHandler(server)
422+
423+
req := httptest.NewRequest("GET", "/events?namespace=default&name=flow-a", nil)
424+
w := httptest.NewRecorder()
425+
handler.ServeHTTP(w, req)
426+
427+
if w.Code != http.StatusOK {
428+
t.Errorf("Expected 200, got %d: %s", w.Code, w.Body.String())
429+
}
430+
431+
var events []corev1.Event
432+
if err := json.NewDecoder(w.Body).Decode(&events); err != nil {
433+
t.Fatalf("Failed to decode: %v", err)
434+
}
435+
// Fake clientset may not filter by field selector; at minimum we get valid response
436+
if len(events) == 0 {
437+
t.Errorf("Expected at least 1 event, got 0")
438+
}
439+
// If filtering works, we get only flow-a; otherwise we may get both
440+
foundFlowA := false
441+
for _, e := range events {
442+
if e.InvolvedObject.Name == "flow-a" {
443+
foundFlowA = true
444+
break
445+
}
446+
}
447+
if !foundFlowA {
448+
t.Errorf("Expected at least one event for flow-a, got %v", events)
449+
}
450+
}
451+
336452
// TestScannerReadsLongLogLine verifies that a scanner with an increased buffer
337453
// reads lines longer than 64 KB without "token too long" error.
338454
func TestScannerReadsLongLogLine(t *testing.T) {

web/src/App.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
<router-link to="/manifests">{{ t('nav.manifests') }}</router-link>
99
<router-link to="/logs">{{ t('nav.logs') }}</router-link>
1010
<router-link to="/metrics">{{ t('nav.metrics') }}</router-link>
11+
<router-link to="/events">{{ t('nav.events') }}</router-link>
1112
</nav>
1213
<div class="header-controls">
1314
<select

web/src/api/client.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,9 @@ export async function getMetrics(namespace, name) {
9090
`/metrics?namespace=${encodeURIComponent(namespace)}&name=${encodeURIComponent(name)}`
9191
)
9292
}
93+
94+
export async function getEvents(namespace, name = null) {
95+
const params = new URLSearchParams({ namespace })
96+
if (name) params.set('name', name)
97+
return request(`/events?${params.toString()}`)
98+
}

web/src/api/client.test.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
deleteDataFlow,
99
getLogs,
1010
getStatus,
11+
getEvents,
1112
} from './client'
1213

1314
describe('API client', () => {
@@ -93,6 +94,26 @@ describe('API client', () => {
9394
expect(result.processedCount).toBe(10)
9495
})
9596

97+
it('getEvents returns all events when name is null', async () => {
98+
const events = [{ type: 'Normal', reason: 'ConfigMapCreated', message: 'Created ConfigMap' }]
99+
fetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(events) })
100+
const result = await getEvents('default')
101+
expect(fetch).toHaveBeenCalledWith(
102+
expect.stringMatching(/\?namespace=default$/),
103+
expect.any(Object)
104+
)
105+
expect(result).toEqual(events)
106+
})
107+
108+
it('getEvents adds name param when filtering by manifest', async () => {
109+
fetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve([]) })
110+
await getEvents('default', 'my-dataflow')
111+
expect(fetch).toHaveBeenCalledWith(
112+
expect.stringMatching(/name=my-dataflow/),
113+
expect.any(Object)
114+
)
115+
})
116+
96117
it('throws on non-ok response', async () => {
97118
fetch.mockResolvedValueOnce({ ok: false, text: () => Promise.resolve('Not found') })
98119
await expect(getNamespaces()).rejects.toThrow()

web/src/locales/en.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export default {
88
manifests: 'Manifests',
99
logs: 'Logs',
1010
metrics: 'Metrics',
11+
events: 'Events',
1112
},
1213
theme: {
1314
light: 'Light theme',
@@ -66,6 +67,22 @@ export default {
6667
copied: 'Logs copied',
6768
selectDataflow: 'Select DataFlow',
6869
},
70+
events: {
71+
title: 'Kubernetes Events',
72+
filterAll: 'All DataFlow events',
73+
filterByManifest: 'Filter by manifest',
74+
selectManifest: 'Select manifest',
75+
loading: 'Loading events...',
76+
empty: 'No events',
77+
emptyFiltered: 'No events for this manifest',
78+
refresh: 'Refresh',
79+
type: 'Type',
80+
reason: 'Reason',
81+
message: 'Message',
82+
object: 'Object',
83+
time: 'Time',
84+
count: 'Count',
85+
},
6986
metrics: {
7087
title: 'Metrics and processing status',
7188
selectDataflow: 'Select DataFlow',

web/src/locales/ru.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export default {
88
manifests: 'Манифесты',
99
logs: 'Логи',
1010
metrics: 'Метрики',
11+
events: 'События',
1112
},
1213
theme: {
1314
light: 'Светлая тема',
@@ -66,6 +67,22 @@ export default {
6667
copied: 'Логи скопированы',
6768
selectDataflow: 'Выберите DataFlow',
6869
},
70+
events: {
71+
title: 'События Kubernetes',
72+
filterAll: 'Все события DataFlow',
73+
filterByManifest: 'Фильтр по манифесту',
74+
selectManifest: 'Выберите манифест',
75+
loading: 'Загрузка событий...',
76+
empty: 'Нет событий',
77+
emptyFiltered: 'Нет событий для этого манифеста',
78+
refresh: 'Обновить',
79+
type: 'Тип',
80+
reason: 'Причина',
81+
message: 'Сообщение',
82+
object: 'Объект',
83+
time: 'Время',
84+
count: 'Кол-во',
85+
},
6986
metrics: {
7087
title: 'Метрики и статус обработки',
7188
selectDataflow: 'Выберите DataFlow',

web/src/router/index.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ const routes = [
2626
component: () => import('../views/MetricsView.vue'),
2727
meta: { titleKey: 'nav.metrics' },
2828
},
29+
{
30+
path: '/events',
31+
name: 'events',
32+
component: () => import('../views/EventsView.vue'),
33+
meta: { titleKey: 'nav.events' },
34+
},
2935
]
3036

3137
const router = createRouter({

0 commit comments

Comments
 (0)