Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/e2e/qemu/clusters.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 0 additions & 5 deletions frontend/eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 0 additions & 7 deletions frontend/src/api/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/TInput/TInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ onMounted(() => focus && inputRef.value?.focus())
<div
class="flex max-h-full items-center justify-start gap-x-2 gap-y-3 rounded border transition-colors focus-within:border-naturals-n5 has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:border-naturals-n6 has-disabled:bg-naturals-n3 has-disabled:text-naturals-n9 has-disabled:select-none"
:class="[
compact ? 'px-2 py-1' : 'p-2',
compact ? 'px-2 py-1' : 'px-2 py-2.25',
secondary ? 'border-transparent' : 'border-naturals-n8',
]"
>
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import type { WatchContext } from '@/api/watch'

export const current = useLocalStorage<string>('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)

Expand All @@ -25,7 +28,7 @@ export function getContext(route = useRoute()): WatchContext {
return res
}

export function clusterName(route = useRoute()) {
function clusterName(route: ReturnType<typeof useRoute>) {
if ('cluster' in route.params) {
return route.params.cluster
}
Expand Down
18 changes: 6 additions & 12 deletions frontend/src/methods/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ export class LineDelimitedLogParser {
export const setupLogStream = <R extends Data, T>(
logs: Ref<LogLine[]>,
method: StreamingRequest<R, T>,
params: MaybeRefOrGetter<T | undefined>,
logParser: LogParser = new DefaultLogParser((msg) => ({ msg })),
params: MaybeRefOrGetter<T>,
logParser: MaybeRefOrGetter<LogParser> = new DefaultLogParser((msg) => ({ msg })),
...options: fetchOption[]
) => {
let clearLogs = false
Expand All @@ -89,19 +89,13 @@ export const setupLogStream = <R extends Data, T>(

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)

Expand All @@ -118,7 +112,7 @@ export const setupLogStream = <R extends Data, T>(
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)
}
Expand All @@ -134,7 +128,7 @@ export const setupLogStream = <R extends Data, T>(
)

return {
...stream,
stream,
shutdown() {
reset()
stream.shutdown()
Expand Down
73 changes: 73 additions & 0 deletions frontend/src/methods/useMachineServices.ts
Original file line number Diff line number Diff line change
@@ -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<WatchContext>) {
const services = ref<Service[]>([])
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 }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,13 @@ included in the LICENSE file.
<script setup lang="ts">
import type { NodeSpec as V1NodeSpec, NodeStatus as V1NodeStatus } from 'kubernetes-types/core/v1'
import { DateTime } from 'luxon'
import { computed, ref, useId, watchEffect } from 'vue'
import { computed, ref, useId } from 'vue'
import { useRoute } from 'vue-router'

import { Runtime } from '@/api/common/omni.pb'
import { subscribe } from '@/api/grpc'
import type { MachineStatusLinkSpec } from '@/api/omni/specs/ephemeral.pb'
import type { ClusterMachineStatusSpec } from '@/api/omni/specs/omni.pb'
import { ConfigApplyStatus } from '@/api/omni/specs/omni.pb'
import { withAbortController, withContext, withRuntime } from '@/api/options'
import {
ClusterMachineStatusType,
DefaultNamespace,
Expand All @@ -32,8 +30,6 @@ import {
TalosNodenameType,
TalosRuntimeNamespace,
} from '@/api/resources'
import type { ServiceEvent } from '@/api/talos/machine/machine.pb'
import { MachineService } from '@/api/talos/machine/machine.pb'
import TGroupAnimation from '@/components/Animation/TGroupAnimation.vue'
import TIcon from '@/components/Icon/TIcon.vue'
import TListItem from '@/components/List/TListItem.vue'
Expand All @@ -45,6 +41,7 @@ import { TCommonStatuses } from '@/constants'
import { getContext } from '@/context'
import { formatBytes, getStatus } from '@/methods'
import { addMachineLabels, removeMachineLabels } from '@/methods/machine'
import { useMachineServices } from '@/methods/useMachineServices'
import { useResourceWatch } from '@/methods/useResourceWatch'
import ClusterMachinePhase from '@/views/ClusterMachines/ClusterMachinePhase.vue'
import ItemLabels from '@/views/ItemLabels/ItemLabels.vue'
Expand All @@ -57,72 +54,7 @@ definePage({ name: 'NodeOverview' })
const route = useRoute()
const context = computed(() => getContext(route))

const services = ref<
{
name?: string
state?: string
status: TCommonStatuses
events?: ServiceEvent[]
}[]
>([])

let abortController: AbortController | undefined

const fetchServices = async () => {
if (abortController) {
abortController.abort()
}

abortController = new AbortController()

const res = await MachineService.ServiceList(
{},
withContext(context.value),
withRuntime(Runtime.Talos),
withAbortController(abortController),
)

abortController = undefined

services.value = []

res.messages?.forEach((message) =>
message.services?.forEach((service) =>
services.value.push({
name: service.id,
state: service.state,
status: service.health?.unknown
? TCommonStatuses.HEALTH_UNKNOWN
: service.health?.healthy
? TCommonStatuses.HEALTHY
: TCommonStatuses.UNHEALTHY,
events: service.events?.events,
}),
),
)
}

fetchServices()

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')) {
fetchServices()
}
},
[withRuntime(Runtime.Talos), withContext(context.value)],
)

onCleanup(() => {
stream.shutdown()
})
})
const { services } = useMachineServices(context)

const configApplyStatusToConfigApplyStatusName = (status?: ConfigApplyStatus): string => {
switch (status) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<!--
Copyright (c) 2026 Sidero Labs, Inc.

Use of this software is governed by the Business Source License
included in the LICENSE file.
-->
<script setup lang="ts">
import PageContainer from '@/components/PageContainer/PageContainer.vue'
import MachineLogsContainer from '@/views/Machines/MachineLogsContainer.vue'

definePage({ name: 'NodeLogs' })
</script>

<template>
<PageContainer class="flex h-full max-h-[calc(100vh-150px)] flex-col overflow-hidden">
<MachineLogsContainer
:cluster-id="$route.params.cluster"
:machine-id="$route.params.machine"
class="flex grow flex-col"
:service="$route.params.service"
/>
</PageContainer>
</template>
Loading
Loading