From 8bdc2b6c612305e7a563a9de573d7aeb58ad5639 Mon Sep 17 00:00:00 2001 From: Edward Sammut Alessi Date: Wed, 6 May 2026 15:25:02 +0200 Subject: [PATCH 1/3] refactor(frontend): extra machine service list into a composable Extract logic to get list of machine services into a useMachineServices composable. Signed-off-by: Edward Sammut Alessi --- frontend/src/methods/useMachineServices.ts | 73 ++++++++++++++++++ .../[cluster]/machine/[machine]/index.vue | 74 +------------------ 2 files changed, 76 insertions(+), 71 deletions(-) create mode 100644 frontend/src/methods/useMachineServices.ts diff --git a/frontend/src/methods/useMachineServices.ts b/frontend/src/methods/useMachineServices.ts new file mode 100644 index 000000000..f6c1a929b --- /dev/null +++ b/frontend/src/methods/useMachineServices.ts @@ -0,0 +1,73 @@ +// Copyright (c) 2026 Sidero Labs, Inc. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +import { type MaybeRefOrGetter, ref, toValue, watchEffect } from 'vue' + +import { Runtime } from '@/api/common/omni.pb' +import { subscribe } from '@/api/grpc' +import { withAbortController, withContext, withRuntime } from '@/api/options' +import { MachineService, type ServiceEvent } from '@/api/talos/machine/machine.pb' +import type { WatchContext } from '@/api/watch' +import { TCommonStatuses } from '@/constants' + +interface Service { + name?: string + state?: string + status: TCommonStatuses + events?: ServiceEvent[] +} + +export function useMachineServices(context: MaybeRefOrGetter) { + const services = ref([]) + const serviceListVersion = ref(0) + + watchEffect(async (onCleanup) => { + // To track for forced updates + void serviceListVersion.value + + const abortController = new AbortController() + onCleanup(() => abortController.abort()) + + const { messages = [] } = await MachineService.ServiceList( + {}, + withRuntime(Runtime.Talos), + withContext(toValue(context)), + withAbortController(abortController), + ) + + services.value = messages.flatMap(({ services = [] }) => + services.map((service) => ({ + name: service.id, + state: service.state, + status: service.health?.unknown + ? TCommonStatuses.HEALTH_UNKNOWN + : service.health?.healthy + ? TCommonStatuses.HEALTHY + : TCommonStatuses.UNHEALTHY, + events: service.events?.events, + })), + ) + }) + + watchEffect((onCleanup) => { + const stream = subscribe( + MachineService.Events, + {}, + (event) => { + // For some reason @type is not typed on Any + const data = event.data as (typeof event.data & { ['@type']?: string }) | undefined + + if (data?.['@type']?.includes('machine.ServiceStateEvent')) { + // Trigger a services refetch + serviceListVersion.value++ + } + }, + [withRuntime(Runtime.Talos), withContext(toValue(context))], + ) + + onCleanup(() => stream.shutdown()) + }) + + return { services } +} diff --git a/frontend/src/pages/(authenticated)/clusters/[cluster]/machine/[machine]/index.vue b/frontend/src/pages/(authenticated)/clusters/[cluster]/machine/[machine]/index.vue index 6314781ab..8022e7f15 100644 --- a/frontend/src/pages/(authenticated)/clusters/[cluster]/machine/[machine]/index.vue +++ b/frontend/src/pages/(authenticated)/clusters/[cluster]/machine/[machine]/index.vue @@ -7,15 +7,13 @@ included in the LICENSE file. + + diff --git a/frontend/src/pages/(authenticated)/clusters/[cluster]/machine/[machine]/logs/[service].vue b/frontend/src/pages/(authenticated)/clusters/[cluster]/machine/[machine]/logs/[service].vue deleted file mode 100644 index d349a17ce..000000000 --- a/frontend/src/pages/(authenticated)/clusters/[cluster]/machine/[machine]/logs/[service].vue +++ /dev/null @@ -1,190 +0,0 @@ - - - - - - diff --git a/frontend/src/pages/(authenticated)/machines/[machine]/logs.vue b/frontend/src/pages/(authenticated)/machines/[machine]/logs/[[service]].vue similarity index 75% rename from frontend/src/pages/(authenticated)/machines/[machine]/logs.vue rename to frontend/src/pages/(authenticated)/machines/[machine]/logs/[[service]].vue index 7cb8ad962..74bc44c47 100644 --- a/frontend/src/pages/(authenticated)/machines/[machine]/logs.vue +++ b/frontend/src/pages/(authenticated)/machines/[machine]/logs/[[service]].vue @@ -13,6 +13,10 @@ definePage({ name: 'MachineLogs' }) diff --git a/frontend/src/views/Machines/MachineLogsContainer.vue b/frontend/src/views/Machines/MachineLogsContainer.vue index 6097af52c..7550974ba 100644 --- a/frontend/src/views/Machines/MachineLogsContainer.vue +++ b/frontend/src/views/Machines/MachineLogsContainer.vue @@ -5,48 +5,144 @@ Use of this software is governed by the Business Source License included in the LICENSE file. --> @@ -55,6 +151,19 @@ watchEffect((onCleanup) => {
- + + {{ stream.err }} + + diff --git a/frontend/typed-router.d.ts b/frontend/typed-router.d.ts index 1886e02a3..8025f4e39 100644 --- a/frontend/typed-router.d.ts +++ b/frontend/typed-router.d.ts @@ -218,9 +218,9 @@ declare module 'vue-router/auto-routes' { >, 'NodeLogs': RouteRecordInfo< 'NodeLogs', - '/clusters/:cluster/machine/:machine/logs/:service', - { cluster: ParamValue, machine: ParamValue, service: ParamValue }, - { cluster: ParamValue, machine: ParamValue, service: ParamValue }, + '/clusters/:cluster/machine/:machine/logs/:service?', + { cluster: ParamValue, machine: ParamValue, service?: ParamValueZeroOrOne }, + { cluster: ParamValue, machine: ParamValue, service?: ParamValueZeroOrOne }, | never >, 'NodeMonitor': RouteRecordInfo< @@ -371,9 +371,9 @@ declare module 'vue-router/auto-routes' { >, 'MachineLogs': RouteRecordInfo< 'MachineLogs', - '/machines/:machine/logs', - { machine: ParamValue }, - { machine: ParamValue }, + '/machines/:machine/logs/:service?', + { machine: ParamValue, service?: ParamValueZeroOrOne }, + { machine: ParamValue, service?: ParamValueZeroOrOne }, | never >, 'MachineConfigPatches': RouteRecordInfo< @@ -850,7 +850,7 @@ declare module 'vue-router/auto-routes' { views: | never } - 'src/pages/(authenticated)/clusters/[cluster]/machine/[machine]/logs/[service].vue': { + 'src/pages/(authenticated)/clusters/[cluster]/machine/[machine]/logs/[[service]].vue': { routes: | 'NodeLogs' views: @@ -983,7 +983,7 @@ declare module 'vue-router/auto-routes' { views: | never } - 'src/pages/(authenticated)/machines/[machine]/logs.vue': { + 'src/pages/(authenticated)/machines/[machine]/logs/[[service]].vue': { routes: | 'MachineLogs' views: From 292f0bcf20826dcefc4d75c676f511ad111af467 Mon Sep 17 00:00:00 2001 From: Edward Sammut Alessi Date: Wed, 6 May 2026 19:17:55 +0200 Subject: [PATCH 3/3] feat(frontend): allow switching logs inside logs tab Allow switching between machine and service logs from inside the logs tab via a dropdown of services Signed-off-by: Edward Sammut Alessi --- frontend/src/components/TInput/TInput.vue | 2 +- .../views/Machines/MachineLogsContainer.vue | 42 +++++++++++++++++-- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/TInput/TInput.vue b/frontend/src/components/TInput/TInput.vue index e97b4b541..a428ff3d6 100644 --- a/frontend/src/components/TInput/TInput.vue +++ b/frontend/src/components/TInput/TInput.vue @@ -97,7 +97,7 @@ onMounted(() => focus && inputRef.value?.focus())
diff --git a/frontend/src/views/Machines/MachineLogsContainer.vue b/frontend/src/views/Machines/MachineLogsContainer.vue index 7550974ba..683d763e7 100644 --- a/frontend/src/views/Machines/MachineLogsContainer.vue +++ b/frontend/src/views/Machines/MachineLogsContainer.vue @@ -15,11 +15,13 @@ import { ManagementService } from '@/api/omni/management/management.pb' import { withContext, withRuntime } from '@/api/options' import { type LogsRequest, MachineService } from '@/api/talos/machine/machine.pb' import LogViewer from '@/components/LogViewer/LogViewer.vue' +import TSelectList from '@/components/SelectList/TSelectList.vue' import TAlert from '@/components/TAlert.vue' import TInput from '@/components/TInput/TInput.vue' import type { LogLine } from '@/methods/logs' import { DefaultLogParser, LineDelimitedLogParser, setupLogStream } from '@/methods/logs' import { formatISO } from '@/methods/time' +import { useMachineServices } from '@/methods/useMachineServices' const { clusterId, machineId, service } = defineProps<{ clusterId?: string @@ -34,8 +36,19 @@ const context = computed(() => ({ node: machineId, })) +const MACHINE_LOGS = 'machine' // dummy value to represent non-service machine logs +const CONTROLLER_RUNTIME = 'controller-runtime' // service with logs that isn't reported in services list + // 'machine' check to continue support for /logs/machine but it is equivalent to /logs -const isMachineLogs = computed(() => !service || service === 'machine') +const isMachineLogs = computed(() => !service || service === MACHINE_LOGS) + +const { services } = useMachineServices(context) + +const servicesSelectValues = computed(() => [ + MACHINE_LOGS, + CONTROLLER_RUNTIME, + ...services.value.map((s) => s.name ?? ''), +]) const formatLoggingContext = (logRecord: Record, ...exceptFields: string[]) => { const res: string[] = [] @@ -85,6 +98,20 @@ const parsers: Record LogLine> = { msg: `[${parsed.level ?? 'info'}] ${parsed.msg} ${formatLoggingContext(parsed, 'msg', 'ts', 'level')}`, } }, + [CONTROLLER_RUNTIME](line) { + // Format: LEVEL + const stripped = line.replace(/\x1b\[[\d;]*m/g, '') + const match = stripped.match(/^(\S+)\s+(\w+)\s+(.*?)\s+(\{.*\})$/) + if (!match) throw new Error('Unexpected line format') + + const [, date, level, msg, contextJson] = match + const parsed = JSON.parse(contextJson) + + return { + date, + msg: `[${level.toLowerCase()}] ${msg} ${formatLoggingContext(parsed, 'component')}`, + } + }, } const plainText = (line: string) => { @@ -148,8 +175,17 @@ watchEffect((onCleanup) => {