diff --git a/frontend/e2e/qemu/clusters.spec.ts b/frontend/e2e/qemu/clusters.spec.ts
index 9697329a9..3985be3eb 100644
--- a/frontend/e2e/qemu/clusters.spec.ts
+++ b/frontend/e2e/qemu/clusters.spec.ts
@@ -354,7 +354,7 @@ test('node overview tabs', async ({ page }) => {
})
await test.step('Validate console logs tab', async () => {
- await page.getByRole('tab', { name: 'Console Logs', exact: true }).click()
+ await page.getByRole('tab', { name: 'Logs', exact: true }).click()
await page.getByPlaceholder('Search').fill('[talos] [initramfs] booting Talos')
await expect
diff --git a/frontend/eslint-suppressions.json b/frontend/eslint-suppressions.json
index a57a2183b..bc378e4b8 100644
--- a/frontend/eslint-suppressions.json
+++ b/frontend/eslint-suppressions.json
@@ -108,11 +108,6 @@
"count": 1
}
},
- "src/pages/(authenticated)/clusters/[cluster]/machine/[machine]/logs/[service].vue": {
- "vue/no-ref-object-reactivity-loss": {
- "count": 1
- }
- },
"src/views/ClusterMachines/MachineRequest.vue": {
"vue/define-props-destructuring": {
"count": 1
diff --git a/frontend/src/api/options.ts b/frontend/src/api/options.ts
index f4d9c9222..c43ec5b14 100644
--- a/frontend/src/api/options.ts
+++ b/frontend/src/api/options.ts
@@ -2,8 +2,6 @@
//
// Use of this software is governed by the Business Source License
// included in the LICENSE file.
-
-import { clusterName } from '@/context'
import type { OmniRequestOptions } from '@/methods/interceptor'
import { Runtime } from './common/omni.pb'
@@ -56,11 +54,6 @@ export const withContext = (context: WatchContext) => {
if (context.cluster) {
md.cluster = context.cluster
- } else {
- const currentContext = clusterName()
- if (currentContext) {
- md.cluster = md.cluster || currentContext
- }
}
if (context.node) {
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/context.ts b/frontend/src/context.ts
index d275252eb..b8cb3cc78 100644
--- a/frontend/src/context.ts
+++ b/frontend/src/context.ts
@@ -10,6 +10,9 @@ import type { WatchContext } from '@/api/watch'
export const current = useLocalStorage
('context', null)
+/**
+ * @deprecated Pass context in explicitly instead of using this function. It relies on routing & local storage and can be flimsy.
+ */
export function getContext(route = useRoute()): WatchContext {
const cluster = clusterName(route)
@@ -25,7 +28,7 @@ export function getContext(route = useRoute()): WatchContext {
return res
}
-export function clusterName(route = useRoute()) {
+function clusterName(route: ReturnType) {
if ('cluster' in route.params) {
return route.params.cluster
}
diff --git a/frontend/src/methods/logs.ts b/frontend/src/methods/logs.ts
index e4150e242..f62ed1a9c 100644
--- a/frontend/src/methods/logs.ts
+++ b/frontend/src/methods/logs.ts
@@ -79,8 +79,8 @@ export class LineDelimitedLogParser {
export const setupLogStream = (
logs: Ref,
method: StreamingRequest,
- params: MaybeRefOrGetter,
- logParser: LogParser = new DefaultLogParser((msg) => ({ msg })),
+ params: MaybeRefOrGetter,
+ logParser: MaybeRefOrGetter = new DefaultLogParser((msg) => ({ msg })),
...options: fetchOption[]
) => {
let clearLogs = false
@@ -89,19 +89,13 @@ export const setupLogStream = (
const reset = () => {
logs.value = []
- logParser.reset()
+ toValue(logParser).reset()
clearTimeout(flush)
}
- const p = toValue(params)
-
- if (!p) {
- return
- }
-
const stream = subscribe(
method,
- p,
+ toValue(params),
(resp: Data & { error?: string }) => {
clearTimeout(flush)
@@ -118,7 +112,7 @@ export const setupLogStream = (
const line = window.atob(resp.bytes.toString())
try {
- buffer.push(...logParser.parse(line))
+ buffer.push(...toValue(logParser).parse(line))
} catch (e) {
console.error(`failed to parse line ${line}`, e)
}
@@ -134,7 +128,7 @@ export const setupLogStream = (
)
return {
- ...stream,
+ stream,
shutdown() {
reset()
stream.shutdown()
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].vue b/frontend/src/pages/(authenticated)/clusters/[cluster]/machine/[machine].vue
index a62a3e3d4..d292fe8d8 100644
--- a/frontend/src/pages/(authenticated)/clusters/[cluster]/machine/[machine].vue
+++ b/frontend/src/pages/(authenticated)/clusters/[cluster]/machine/[machine].vue
@@ -32,8 +32,8 @@ const routes = computed(() => {
to: { name: 'NodeMonitor', params: { machine: machine.value } },
},
{
- name: 'Console Logs',
- to: { name: 'NodeLogs', params: { machine: machine.value, service: 'machine' } },
+ name: 'Logs',
+ to: { name: 'NodeLogs', params: { machine: machine.value } },
},
{
name: 'Config',
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 @@
-
-
-
-
-
-
-
-
-
-
-
- {{ err }}
-
-
-
-
-
-
-
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..683d763e7 100644
--- a/frontend/src/views/Machines/MachineLogsContainer.vue
+++ b/frontend/src/views/Machines/MachineLogsContainer.vue
@@ -5,56 +5,201 @@ Use of this software is governed by the Business Source License
included in the LICENSE file.
-->
-
-
+
+ $router.replace({ params: { service: v === MACHINE_LOGS ? '' : v } })
+ "
+ />
+
+
-
+
+ {{ 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: