From 17512e88f8e5aed49681f6fb0bfaa207394efa9d Mon Sep 17 00:00:00 2001 From: Bruce Schultz Date: Fri, 24 Apr 2026 10:57:51 +0200 Subject: [PATCH 01/11] fix(logs): make scroll to bottom less aggressive --- .../analysis/logs/AnalysisLogCardContent.vue | 40 +++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/app/components/analysis/logs/AnalysisLogCardContent.vue b/app/components/analysis/logs/AnalysisLogCardContent.vue index faad238..89f5097 100644 --- a/app/components/analysis/logs/AnalysisLogCardContent.vue +++ b/app/components/analysis/logs/AnalysisLogCardContent.vue @@ -1,6 +1,6 @@ + + + + From 248a5fe9b0289ff95d3fb0b6bd76d9d68e75afe3 Mon Sep 17 00:00:00 2001 From: Bruce Schultz Date: Wed, 29 Apr 2026 10:32:56 +0200 Subject: [PATCH 08/11] perf(logs): improve log fetching --- .../analysis/logs/ContainerLogs.vue | 46 +- app/composables/useAPIFetch.ts | 7 +- app/services/Api.ts | 253 +++++++- app/services/hub_adapter_swagger.json | 590 +++++++++++++++++- 4 files changed, 888 insertions(+), 8 deletions(-) diff --git a/app/components/analysis/logs/ContainerLogs.vue b/app/components/analysis/logs/ContainerLogs.vue index 6897003..06f7133 100644 --- a/app/components/analysis/logs/ContainerLogs.vue +++ b/app/components/analysis/logs/ContainerLogs.vue @@ -23,10 +23,11 @@ const analysis = ref(null); const { data: response, - refresh, status, error, -} = await getAnalysisLogs(analysisId); +} = await getAnalysisLogs(analysisId, { limit: null }); + +const lastFetchedAt = ref(null); gatherCurrentLogs(); await Promise.all([gatherPreviousLogs(), fetchAnalysis()]); @@ -34,6 +35,9 @@ await Promise.all([gatherPreviousLogs(), fetchAnalysis()]); function gatherCurrentLogs() { if (status.value === "success") { currentLogs.value = response.value ?? null; + if (currentLogs.value) { + lastFetchedAt.value = new Date().toISOString(); + } } else if (status.value === "error" && error.value?.statusCode === 403) { navigateTo("/error/403"); } @@ -57,6 +61,9 @@ async function gatherPreviousLogs() { const historyResp = (await useNuxtApp() .$hubApi(`/history/${analysisId}`, { method: "GET", + query: { + limit: null, + }, }) .catch(() => undefined)) as AnalysisLogHistoryResponse | undefined; @@ -80,8 +87,39 @@ const { pause, resume, isActive } = useIntervalFn( ); async function refreshLogs() { - await refresh(); - gatherCurrentLogs(); + if (!lastFetchedAt.value) { + const fetchTime = new Date().toISOString(); + const result = await useNuxtApp() + .$hubApi(`/logs/${analysisId}`, { + method: "GET", + query: { limit: null }, + }) + .catch(() => undefined); + if (result) { + currentLogs.value = result; + lastFetchedAt.value = fetchTime; + } + return; + } + + const fetchTime = new Date().toISOString(); + const result = await useNuxtApp() + .$hubApi(`/logs/${analysisId}`, { + method: "GET", + query: { start_date: lastFetchedAt.value }, + }) + .catch(() => undefined); + if (result && currentLogs.value) { + currentLogs.value = { + ...currentLogs.value, + analysis_logs: [ + ...currentLogs.value.analysis_logs, + ...result.analysis_logs, + ], + nginx_logs: [...currentLogs.value.nginx_logs, ...result.nginx_logs], + }; + } + lastFetchedAt.value = fetchTime; } const previousRunsList = computed(() => diff --git a/app/composables/useAPIFetch.ts b/app/composables/useAPIFetch.ts index c94dd7f..d4a4ba4 100644 --- a/app/composables/useAPIFetch.ts +++ b/app/composables/useAPIFetch.ts @@ -156,10 +156,15 @@ export function deleteAnalysisFromKong(analysisId: string, opts?) { } // PodOrc endpoints -export function getAnalysisLogs(analysisId: string, opts?) { +export function getAnalysisLogs( + analysisId: string, + query?: { limit?: number | null; start_date?: string | null }, + opts?, +) { return useAPIFetch(`/logs/${analysisId}`, { ...opts, method: "GET", + query, }); } diff --git a/app/services/Api.ts b/app/services/Api.ts index 6078c66..613b87f 100644 --- a/app/services/Api.ts +++ b/app/services/Api.ts @@ -405,6 +405,19 @@ export interface AnalysisStatus { progress?: number | null; } +/** + * ApiRequestCountResponse + * Response for the API request count endpoint. + * + * data maps endpoint path → {method: count, ..., "total": count}. + */ +export interface ApiRequestCountResponse { + /** Total */ + total: number; + /** Data */ + data: Record>; +} + /** * AutostartSettings * Autostart Settings. @@ -1162,6 +1175,40 @@ export interface ListServices { offset?: string | null; } +/** + * LogQLQueryRequest + * Request body for a raw LogQL query. + */ +export interface LogQLQueryRequest { + /** Query */ + query: string; + /** + * Limit + * @default 50 + */ + limit?: number; + /** + * Offset + * @default 0 + */ + offset?: number; + /** Start */ + start?: string | null; + /** End */ + end?: string | null; +} + +/** + * LogQLQueryResponse + * Response for a raw LogQL query. + */ +export interface LogQLQueryResponse { + /** Data */ + data: Record[]; + /** Event log metadata model. */ + meta: Meta; +} + /** MasterImage */ export interface MasterImage { /** @@ -1225,7 +1272,7 @@ export interface Meta { /** Limit */ limit: number; /** Offset */ - offset: number; + offset?: number | null; } /** @@ -1259,6 +1306,48 @@ export interface MinioConfig { strip_path_pattern?: string | null; } +/** NetStatResponse */ +export interface NetStatResponse { + /** Data */ + data: NetStatTotal[]; + /** Event log metadata model. */ + meta: Meta; +} + +/** NetStatRun */ +export interface NetStatRun { + /** + * Timestamp + * @format date-time + */ + timestamp: string; + /** Container */ + container: string; + /** Run Number */ + run_number: number; + /** Pod */ + pod: string; + /** Bytes In */ + bytes_in: number; + /** Bytes Out */ + bytes_out: number; +} + +/** NetStatTotal */ +export interface NetStatTotal { + /** + * Analysis Id + * @format uuid + */ + analysis_id: string; + /** Bytes In */ + bytes_in: number; + /** Bytes Out */ + bytes_out: number; + /** Runs */ + runs: NetStatRun[]; +} + /** Node */ export interface Node { /** External Name */ @@ -3428,15 +3517,63 @@ export class Api< */ logsAnalysisLiveGetLogsAnalysisIdGet: ( analysisId: string, + query?: { + /** + * Start Date + * Filter logs from this timestamp using ISO8601 format + */ + start_date?: string | null; + /** + * End Date + * Filter logs up to this timestamp using ISO8601 format + */ + end_date?: string | null; + /** + * Limit + * Maximum number of log lines to return per container + * @default 1000 + */ + limit?: number | null; + /** + * Offset + * Number of log lines to skip per container + * @default 0 + */ + offset?: number | null; + }, params: RequestParams = {}, ) => this.request({ path: `/logs/${analysisId}`, method: "GET", + query: query, secure: true, format: "json", ...params, }), + + /** + * @description Execute a raw LogQL query against VictoriaLogs. Admin only. + * + * @tags Logs + * @name LogsQueryRawLogsQueryPost + * @summary Logs.Query.Raw + * @request POST:/logs/query + * @secure + */ + logsQueryRawLogsQueryPost: ( + data: LogQLQueryRequest, + params: RequestParams = {}, + ) => + this.request({ + path: `/logs/query`, + method: "POST", + body: data, + secure: true, + type: ContentType.Json, + format: "json", + ...params, + }), }; history = { /** @@ -3450,11 +3587,125 @@ export class Api< */ logsAnalysisHistoryGetHistoryAnalysisIdGet: ( analysisId: string, + query?: { + /** + * Start Date + * Filter logs from this timestamp using ISO8601 format + */ + start_date?: string | null; + /** + * End Date + * Filter logs up to this timestamp using ISO8601 format + */ + end_date?: string | null; + /** + * Limit + * Maximum number of log lines to return per container + * @default 1000 + */ + limit?: number; + /** + * Offset + * Number of log lines to skip per container + * @default 0 + */ + offset?: number; + }, params: RequestParams = {}, ) => this.request({ path: `/history/${analysisId}`, method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + }; + netstats = { + /** + * @description Retrieve network traffic statistics from netstats log events. + * + * @tags Logs + * @name LogsNetstatsGetNetstatsGet + * @summary Logs.Netstats.Get + * @request GET:/netstats + * @secure + */ + logsNetstatsGetNetstatsGet: ( + query?: { + /** + * Analysis Id + * Filter by analysis UUID + */ + analysis_id?: string | null; + /** + * Start Date + * Filter by start date using ISO8601 format + */ + start_date?: string | null; + /** + * End Date + * Filter by end date using ISO8601 format + */ + end_date?: string | null; + /** + * Limit + * Maximum number of raw log entries to return + * @default 1000 + */ + limit?: number; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/netstats`, + method: "GET", + query: query, + secure: true, + format: "json", + ...params, + }), + }; + requests = { + /** + * @description Get total API request count and a per-endpoint breakdown grouped by HTTP method and path. + * + * @tags Logs + * @name LogsRequestsGetRequestsGet + * @summary Logs.Requests.Get + * @request GET:/requests + * @secure + */ + logsRequestsGetRequestsGet: ( + query?: { + /** + * Start Date + * Filter requests from this timestamp using ISO8601 format + */ + start_date?: string | null; + /** + * End Date + * Filter requests up to this timestamp using ISO8601 format + */ + end_date?: string | null; + /** + * Endpoint + * Filter breakdown to paths starting with this prefix + */ + endpoint?: string | null; + /** + * Method + * Filter breakdown to a specific HTTP method (e.g. GET, POST, DELETE) + */ + method?: string | null; + }, + params: RequestParams = {}, + ) => + this.request({ + path: `/requests`, + method: "GET", + query: query, secure: true, format: "json", ...params, diff --git a/app/services/hub_adapter_swagger.json b/app/services/hub_adapter_swagger.json index bd9b02c..270d2fd 100644 --- a/app/services/hub_adapter_swagger.json +++ b/app/services/hub_adapter_swagger.json @@ -2839,6 +2839,82 @@ "title": "Analysis Id" }, "description": "UUID of the analysis." + }, + { + "name": "start_date", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "description": "Filter logs from this timestamp using ISO8601 format", + "title": "Start Date" + }, + "description": "Filter logs from this timestamp using ISO8601 format" + }, + { + "name": "end_date", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "description": "Filter logs up to this timestamp using ISO8601 format", + "title": "End Date" + }, + "description": "Filter logs up to this timestamp using ISO8601 format" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Maximum number of log lines to return per container", + "default": 1000, + "title": "Limit" + }, + "description": "Maximum number of log lines to return per container" + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "description": "Number of log lines to skip per container", + "default": 0, + "title": "Offset" + }, + "description": "Number of log lines to skip per container" } ], "responses": { @@ -2891,6 +2967,68 @@ "title": "Analysis Id" }, "description": "UUID of the analysis." + }, + { + "name": "start_date", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "description": "Filter logs from this timestamp using ISO8601 format", + "title": "Start Date" + }, + "description": "Filter logs from this timestamp using ISO8601 format" + }, + { + "name": "end_date", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "description": "Filter logs up to this timestamp using ISO8601 format", + "title": "End Date" + }, + "description": "Filter logs up to this timestamp using ISO8601 format" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "description": "Maximum number of log lines to return per container", + "default": 1000, + "title": "Limit" + }, + "description": "Maximum number of log lines to return per container" + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "description": "Number of log lines to skip per container", + "default": 0, + "title": "Offset" + }, + "description": "Number of log lines to skip per container" } ], "responses": { @@ -2919,6 +3057,277 @@ } } } + }, + "/netstats": { + "get": { + "tags": ["Logs"], + "summary": "Logs.Netstats.Get", + "description": "Retrieve network traffic statistics from netstats log events.", + "operationId": "logs_netstats_get_netstats_get", + "security": [ + { + "JWT": [] + } + ], + "parameters": [ + { + "name": "analysis_id", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "uuid" + }, + { + "type": "null" + } + ], + "description": "Filter by analysis UUID", + "title": "Analysis Id" + }, + "description": "Filter by analysis UUID" + }, + { + "name": "start_date", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "description": "Filter by start date using ISO8601 format", + "title": "Start Date" + }, + "description": "Filter by start date using ISO8601 format" + }, + { + "name": "end_date", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "description": "Filter by end date using ISO8601 format", + "title": "End Date" + }, + "description": "Filter by end date using ISO8601 format" + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "description": "Maximum number of raw log entries to return", + "default": 1000, + "title": "Limit" + }, + "description": "Maximum number of raw log entries to return" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NetStatResponse" + } + } + } + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/logs/query": { + "post": { + "tags": ["Logs"], + "summary": "Logs.Query.Raw", + "description": "Execute a raw LogQL query against VictoriaLogs. Admin only.", + "operationId": "logs_query_raw_logs_query_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LogQLQueryRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LogQLQueryResponse" + } + } + } + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "JWT": [] + } + ] + } + }, + "/requests": { + "get": { + "tags": ["Logs"], + "summary": "Logs.Requests.Get", + "description": "Get total API request count and a per-endpoint breakdown grouped by HTTP method and path.", + "operationId": "logs_requests_get_requests_get", + "security": [ + { + "JWT": [] + } + ], + "parameters": [ + { + "name": "start_date", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "description": "Filter requests from this timestamp using ISO8601 format", + "title": "Start Date" + }, + "description": "Filter requests from this timestamp using ISO8601 format" + }, + { + "name": "end_date", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "description": "Filter requests up to this timestamp using ISO8601 format", + "title": "End Date" + }, + "description": "Filter requests up to this timestamp using ISO8601 format" + }, + { + "name": "endpoint", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Filter breakdown to paths starting with this prefix", + "title": "Endpoint" + }, + "description": "Filter breakdown to paths starting with this prefix" + }, + { + "name": "method", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Filter breakdown to a specific HTTP method (e.g. GET, POST, DELETE)", + "title": "Method" + }, + "description": "Filter breakdown to a specific HTTP method (e.g. GET, POST, DELETE)" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiRequestCountResponse" + } + } + } + }, + "404": { + "description": "Not found" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } } }, "components": { @@ -3647,6 +4056,28 @@ "title": "AnalysisStatus", "description": "Status report for an analysis from the PodOrchestrator" }, + "ApiRequestCountResponse": { + "properties": { + "total": { + "type": "integer", + "title": "Total" + }, + "data": { + "additionalProperties": { + "additionalProperties": { + "type": "integer" + }, + "type": "object" + }, + "type": "object", + "title": "Data" + } + }, + "type": "object", + "required": ["total", "data"], + "title": "ApiRequestCountResponse", + "description": "Response for the API request count endpoint.\n\ndata maps endpoint path → {method: count, ..., \"total\": count}." + }, "AutostartSettings": { "properties": { "enabled": { @@ -5497,6 +5928,71 @@ "title": "ListServices", "description": "Custom route list response model." }, + "LogQLQueryRequest": { + "properties": { + "query": { + "type": "string", + "title": "Query" + }, + "limit": { + "type": "integer", + "title": "Limit", + "default": 50 + }, + "offset": { + "type": "integer", + "title": "Offset", + "default": 0 + }, + "start": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "Start" + }, + "end": { + "anyOf": [ + { + "type": "string", + "format": "date-time" + }, + { + "type": "null" + } + ], + "title": "End" + } + }, + "type": "object", + "required": ["query"], + "title": "LogQLQueryRequest", + "description": "Request body for a raw LogQL query." + }, + "LogQLQueryResponse": { + "properties": { + "data": { + "items": { + "additionalProperties": true, + "type": "object" + }, + "type": "array", + "title": "Data" + }, + "meta": { + "$ref": "#/components/schemas/Meta" + } + }, + "type": "object", + "required": ["data", "meta"], + "title": "LogQLQueryResponse", + "description": "Response for a raw LogQL query." + }, "MasterImage": { "properties": { "id": { @@ -5648,12 +6144,19 @@ "title": "Limit" }, "offset": { - "type": "integer", + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], "title": "Offset" } }, "type": "object", - "required": ["count", "total", "limit", "offset"], + "required": ["count", "total", "limit"], "title": "Meta", "description": "Event log metadata model." }, @@ -5709,6 +6212,89 @@ "title": "MinioConfig", "description": "Credentials for accessing a private S3 bucket hosted on MinIO." }, + "NetStatResponse": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/NetStatTotal" + }, + "type": "array", + "title": "Data" + }, + "meta": { + "$ref": "#/components/schemas/Meta" + } + }, + "type": "object", + "required": ["data", "meta"], + "title": "NetStatResponse" + }, + "NetStatRun": { + "properties": { + "timestamp": { + "type": "string", + "format": "date-time", + "title": "Timestamp" + }, + "container": { + "type": "string", + "title": "Container" + }, + "run_number": { + "type": "integer", + "title": "Run Number" + }, + "pod": { + "type": "string", + "title": "Pod" + }, + "bytes_in": { + "type": "integer", + "title": "Bytes In" + }, + "bytes_out": { + "type": "integer", + "title": "Bytes Out" + } + }, + "type": "object", + "required": [ + "timestamp", + "container", + "run_number", + "pod", + "bytes_in", + "bytes_out" + ], + "title": "NetStatRun" + }, + "NetStatTotal": { + "properties": { + "analysis_id": { + "type": "string", + "format": "uuid", + "title": "Analysis Id" + }, + "bytes_in": { + "type": "integer", + "title": "Bytes In" + }, + "bytes_out": { + "type": "integer", + "title": "Bytes Out" + }, + "runs": { + "items": { + "$ref": "#/components/schemas/NetStatRun" + }, + "type": "array", + "title": "Runs" + } + }, + "type": "object", + "required": ["analysis_id", "bytes_in", "bytes_out", "runs"], + "title": "NetStatTotal" + }, "Node": { "properties": { "external_name": { From 0092ee05d5f7e136d02bacef26c75ca43cd3c3f1 Mon Sep 17 00:00:00 2001 From: Bruce Schultz Date: Thu, 30 Apr 2026 12:03:54 +0200 Subject: [PATCH 09/11] feat(auth): hub login fully operational --- app/components/header/AvatarButton.vue | 4 +- app/components/landing/IdpAuthBtns.vue | 2 +- server/routes/flame/api/auth/[...].ts | 70 ++++++++++++-------------- 3 files changed, 35 insertions(+), 41 deletions(-) diff --git a/app/components/header/AvatarButton.vue b/app/components/header/AvatarButton.vue index 5f48a84..c15a1f4 100644 --- a/app/components/header/AvatarButton.vue +++ b/app/components/header/AvatarButton.vue @@ -30,7 +30,9 @@ const menuItems = computed(() => [ icon: isAuthenticated.value ? "pi pi-sign-out" : "pi pi-sign-in", command: () => { // eslint-disable-next-line @typescript-eslint/no-unused-expressions - isAuthenticated.value ? signOut() : signIn(`${idpProvider}`); + isAuthenticated.value + ? signOut({ callbackUrl: "/" }) + : signIn(`${idpProvider}`); }, }, { diff --git a/app/components/landing/IdpAuthBtns.vue b/app/components/landing/IdpAuthBtns.vue index a916dbd..5f33d6a 100644 --- a/app/components/landing/IdpAuthBtns.vue +++ b/app/components/landing/IdpAuthBtns.vue @@ -25,7 +25,7 @@ const idpNameCapitalized: string = class="idp-auth-success" outlined severity="success" - @click="idpProvider === 'hub' ? navigateTo('/auth/hub') : signIn(idpProvider)" + @click="signIn(idpProvider)" >Login with {{ idpNameCapitalized }} diff --git a/server/routes/flame/api/auth/[...].ts b/server/routes/flame/api/auth/[...].ts index c5d69a9..306130a 100644 --- a/server/routes/flame/api/auth/[...].ts +++ b/server/routes/flame/api/auth/[...].ts @@ -1,4 +1,3 @@ -import CredentialsProvider from "next-auth/providers/credentials"; import KeycloakProvider from "next-auth/providers/keycloak"; import AuthentikProvider from "next-auth/providers/authentik"; import OktaProvider from "next-auth/providers/okta"; @@ -65,41 +64,34 @@ function buildProvider() { } case "hub": { - // Hub requires the password grant — use CredentialsProvider so we can build the token request body ourselves. - const hubProvider = - // @ts-expect-error default is an option - CredentialsProvider.default({ - id: "hub", - name: "Hub", - credentials: { - username: { label: "Username", type: "text" }, - password: { label: "Password", type: "password" }, - }, - async authorize(credentials: { username: string; password: string }) { - if (!credentials?.username || !credentials?.password) return null; - - const response = await fetch(`${clientIssuer}/token`, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "password", - username: credentials.username, - password: credentials.password, - }), - }); - - if (!response.ok) return null; - - const token = await response.json(); - return { - id: credentials.username, - name: credentials.username, - access_token: token.access_token, - refresh_token: token.refresh_token, - expires_at: Date.now() + token.expires_in * 1000, - }; + const clientId = process.env.NUXT_IDP_CLIENT_ID ?? "node-ui"; + const clientSecret = process.env.NUXT_IDP_CLIENT_SECRET; + const hubProvider = { + id: "hub", + name: "Hub", + type: "oauth", + idToken: false, + clientId: clientId, + clientSecret: clientSecret, + wellKnown: `${clientIssuer}/.well-known/openid-configuration`, + authorization: { + params: { + scope: "global", }, - }); + }, + profile(profile: { + id: string; + name: string | undefined; + first_name: string | undefined; + last_name: string | undefined; + display_name: string | undefined; + }) { + return { + id: profile.id, + name: profile.name ?? profile.display_name, + }; + }, + }; providers.push(hubProvider); break; } @@ -154,10 +146,10 @@ async function refreshAccessToken(token: JWT) { const clientIssuer = process.env.NUXT_PUBLIC_IDP_ISSUER ?? "http://localhost:8080/realms/flame"; - const internalEndpoint = - process.env.NUXT_PUBLIC_INTERNAL_KEYCLOAK_URL ?? clientIssuer; - - const tokenEndpoint = `${internalEndpoint}/protocol/openid-connect/token`; // Assumes OIDC + const discovery = await fetch( + `${clientIssuer}/.well-known/openid-configuration`, + ).then((r) => r.json()); + const tokenEndpoint: string = discovery.token_endpoint; const response = await fetch(tokenEndpoint, { headers: { "Content-Type": "application/x-www-form-urlencoded" }, From 676edc1b5d120da69bbc4364345cb3b32edc0f09 Mon Sep 17 00:00:00 2001 From: Bruce Schultz Date: Thu, 30 Apr 2026 12:53:16 +0200 Subject: [PATCH 10/11] test(logs): update unit tests --- .../analysis/logs/ContainerLogs.vue | 2 +- .../analysis/logs/ContainerLogs.spec.ts | 14 ++++++++- test/mockapi/handlers.ts | 30 +++++++++++-------- 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/app/components/analysis/logs/ContainerLogs.vue b/app/components/analysis/logs/ContainerLogs.vue index 06f7133..9a68b68 100644 --- a/app/components/analysis/logs/ContainerLogs.vue +++ b/app/components/analysis/logs/ContainerLogs.vue @@ -83,7 +83,7 @@ const { pause, resume, isActive } = useIntervalFn( refreshLogs(); }, 5000, - { immediate: analysis.value!.execution_status === ProcessStatus.Executing }, + { immediate: analysis.value?.execution_status === ProcessStatus.Executing }, ); async function refreshLogs() { diff --git a/test/components/analysis/logs/ContainerLogs.spec.ts b/test/components/analysis/logs/ContainerLogs.spec.ts index 17098cc..f011ced 100644 --- a/test/components/analysis/logs/ContainerLogs.spec.ts +++ b/test/components/analysis/logs/ContainerLogs.spec.ts @@ -1,13 +1,19 @@ import { defineComponent, ref } from "vue"; import { flushPromises, mount } from "@vue/test-utils"; import { beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; +import { http, HttpResponse } from "msw"; import ContainerLogs from "~/components/analysis/logs/ContainerLogs.vue"; import { getAnalysisLogs } from "~/composables/useAPIFetch"; -import { fakeAnalysisId } from "@/test/mockapi/handlers"; +import { + fakeAnalysisId, + fakeAnalysisNodeId, +} from "@/test/mockapi/handlers"; +import { testServer } from "@/test/mockapi/setup"; vi.mock("vue-router", () => ({ useRoute: () => ({ params: { id: fakeAnalysisId }, + query: { nodeId: fakeAnalysisNodeId }, }), })); @@ -59,6 +65,12 @@ describe("ContainerLogs.vue", () => { }); test("Empty analysis logs", async () => { + testServer.use( + http.get(`/analysis-nodes/${fakeAnalysisNodeId}`, () => + new HttpResponse(null, { status: 404 }), + ), + ); + vi.mocked(getAnalysisLogs).mockResolvedValue({ data: ref(null), pending: ref(false), diff --git a/test/mockapi/handlers.ts b/test/mockapi/handlers.ts index 7f1db93..3603921 100644 --- a/test/mockapi/handlers.ts +++ b/test/mockapi/handlers.ts @@ -11,13 +11,17 @@ import { fakeParsedProjects, } from "../components/data-stores/constants"; import { fakeProposalsResp } from "../components/projects/constants"; -import { fakeProjects } from "../components/analysis/constants"; +import { + fakeBaseAnalysisNode, + fakeProjects, +} from "../components/analysis/constants"; import { fakeEventResponse } from "../components/events/constants"; export const fakeValidProposalId = "7f2f3b59-3b6d-4fb6-a900-2a4d5c2ea483"; export const fakeInvalidProposalId = "15518efa-5146-4290-a7cb-95d27f41d991"; export const fakeAnalysisId = "15518efa-5146-4290-a7cb-95d27f41d991"; +export const fakeAnalysisNodeId = "4c4b9b2e-85de-4319-8d68-bcc8247464eb"; export const fakeMissingAnalysisId = "7f2f3b59-3b6d-4fb6-a900-2a4d5c2ea483"; export const fakeBrokenAnalysisId = "ab1fbc92-3dc8-4bdd-9d51-3b571c2d7aaa"; export const fakeInvalidRoleAnalysisId = "1a29aee7-538b-4a02-9fab-b184b1dcdc2a"; @@ -92,17 +96,19 @@ export const handlers = [ // Analysis logs http.get(`/logs/${fakeAnalysisId}`, () => { return HttpResponse.json({ - status: 200, - data: { - analysis: { - [fakeAnalysisId]: ["Starting FlameCoreSDK"], - }, - nginx: { - [fakeAnalysisId]: [ - "/docker-entrypoint.sh: /docker-entrypoint.d/ is not empty", - ], - }, - }, + analysis_id: fakeAnalysisId, + run_number: 1, + analysis_logs: [ + { timestamp: "2025-01-01T00:00:00Z", message: "Starting FlameCoreSDK" }, + ], + nginx_logs: [], + }); + }), + + http.get(`/analysis-nodes/${fakeAnalysisNodeId}`, () => { + return HttpResponse.json({ + ...fakeBaseAnalysisNode, + execution_status: "executing", }); }), From 76b1ca65eedbc5e181d8f9fa2219950498e18c8d Mon Sep 17 00:00:00 2001 From: Bruce Schultz Date: Thu, 30 Apr 2026 12:58:58 +0200 Subject: [PATCH 11/11] chore: catch potential pitfalls --- app/components/analysis/AnalysesTable.vue | 29 ++-- .../analysis/logs/ContainerLogs.vue | 1 + app/pages/auth/hub.vue | 135 ------------------ 3 files changed, 21 insertions(+), 144 deletions(-) delete mode 100644 app/pages/auth/hub.vue diff --git a/app/components/analysis/AnalysesTable.vue b/app/components/analysis/AnalysesTable.vue index 488f63f..5236be1 100644 --- a/app/components/analysis/AnalysesTable.vue +++ b/app/components/analysis/AnalysesTable.vue @@ -147,13 +147,17 @@ async function getExecutionStatusesFromPodOrc(): Promise< method: "GET", }) .catch(() => { - showConnectionErrorToast(toast, { - severity: "warn", - summary: "Missing PO Status Update", - detail: - "Unable to retrieve pod statuses from the PO, relying on information from the Hub", - life: 3000, - }); + if (!podOrcUnreacheable.value) { + podOrcUnreacheable.value = true; + showConnectionErrorToast(toast, { + severity: "warn", + summary: "Missing PO Status Update", + detail: + "Unable to retrieve pod statuses from the PO, relying on information from the Hub", + life: 3000, + }); + } + return undefined; })) as PodProgressResponse; podOrcUnreacheable.value = !podOrcResponse; return podOrcResponse; @@ -260,10 +264,17 @@ async function compileAnalysisTable( } let tableRefreshIntervalId: ReturnType | undefined; +let isPolling = false; async function pollTableData() { - await refresh(); - await compileAnalysisTable(status.value, analysisNodeResp.value, true); + if (isPolling) return; + isPolling = true; + try { + await refresh(); + await compileAnalysisTable(status.value, analysisNodeResp.value, true); + } finally { + isPolling = false; + } } onMounted(() => { diff --git a/app/components/analysis/logs/ContainerLogs.vue b/app/components/analysis/logs/ContainerLogs.vue index 9a68b68..bc4913c 100644 --- a/app/components/analysis/logs/ContainerLogs.vue +++ b/app/components/analysis/logs/ContainerLogs.vue @@ -44,6 +44,7 @@ function gatherCurrentLogs() { } async function fetchAnalysis() { + if (!analysisNodeId) return; const result = (await useNuxtApp() .$hubApi(`/analysis-nodes/${analysisNodeId}`, { method: "GET", diff --git a/app/pages/auth/hub.vue b/app/pages/auth/hub.vue deleted file mode 100644 index 92d67b7..0000000 --- a/app/pages/auth/hub.vue +++ /dev/null @@ -1,135 +0,0 @@ - - - - -