diff --git a/.env.example b/.env.example index 0db7273c9..179deb4f5 100644 --- a/.env.example +++ b/.env.example @@ -1,34 +1,75 @@ -# Docker settings +## Docker settings + +# Traefik requires this file to be able to route the requests to the containers DOCKER_SOCKET=/var/run/docker.sock -# Domain settings +## General + +# Tag for images. It's stable by default DYO_VERSION=stable +# Required for Traefik's certification resolution +# It should be your domain where dyrector.io will be available DOMAIN=example.com +# Your server's timezone TIMEZONE=UTC +# Required for Traefik's certification resolution +# If there's an issue with the certificate, or when it expires, +# letsencrypt will send a notificatiom to this e-mail address ACME_EMAIL=user@example.com +# NodeJS services can run in two modes: production and development +# These are the two values this key can have NODE_ENV=production -# crux service settings +## Crux service settings + +# You can specify how thorough logging will be +# Options: verbose, debug, info, warning, error +# The settings come in a hierarchic order, +# meaning that in the order above they contain each other +# Example: 'warning' contains 'error' LOG_LEVEL=debug -# Database passwords +## Database passwords + +# This value is the password to crux's database CRUX_POSTGRES_PASSWORD=Random_Generated_String +# This value is the password to Kratos' database KRATOS_POSTGRES_PASSWORD=Random_Generated_String -# External URL of the site https://example.com(:port if not 443) +## External URL of the site https://example.com(:port if not 443) + +# This setting is to define where your +# self-managed dyrector.io will be available EXTERNAL_URL=https://example.com -# Cookie/JWT secrets +## Cookie/JWT secrets + +# Secret to sign JWTs. CRUX_SECRET=Random_Generated_String +# Secret to sign Kratos cookies +# More details in Ory/Kratos documentation: +# https://www.ory.sh/docs/kratos/reference/configuration KRATOS_SECRET=Random_Generated_String -# Mailserver settings +## Mailserver settings + +# The connection string for the mail server +# The protocol can be SMTP or SMTPS +# Example: protocol://smtp_user:smtp_password@mailserver_ip_or_domain:port SMTP_URI=smtps://username:password@mailserver.example.com:465 +# E-mail address for dyrector.io invitation links, +# password resets and others FROM_EMAIL=from@example.com +# E-mail sender name for dyrector.io invitation links, +# password resets and others FROM_NAME=dyrector.io -# Recaptcha secrets +## ReCAPTCHA secrets + # In case you don't want to use ReCAPTCHA set DISABLE_RECAPTCHA to true +# Highly recommended to keep the default value, which is `false` DISABLE_RECAPTCHA=false +# Create ReCAPTCHA V2 credentials in the ReCAPTCHA admin console +# It is recommended to use the inivisble type RECAPTCHA_SECRET_KEY=Recaptcha_Secret_Key RECAPTCHA_SITE_KEY=Recaptcha_Site_Key diff --git a/golang/Makefile b/golang/Makefile index 745b23ebd..33b64f5cc 100644 --- a/golang/Makefile +++ b/golang/Makefile @@ -138,32 +138,32 @@ lint: .PHONY: build-cli build-cli: cd build && \ - docker buildx build --build-arg REVISION=$$(git rev-parse --short HEAD) --platform=linux/amd64 --load -t ${ORG_REGISTRY}/cli/dyo:$(image_version) -t ${DOCKER_REGISTRY}/dyo:$(image_version) -f cli/Dockerfile . + docker buildx build --build-arg REVISION=${ORG_GOLANG_HASH} --platform=linux/amd64 --load -t ${ORG_REGISTRY}/cli/dyo:$(image_version) -t ${DOCKER_REGISTRY}/dyo:$(image_version) -f cli/Dockerfile . .PHONY: build-cli-push build-cli-push: cd build && \ - docker buildx build --build-arg REVISION=$$(git rev-parse --short HEAD) --platform=linux/amd64,linux/arm64 --push -t ${ORG_REGISTRY}/cli/dyo:$(image_version) -t ${DOCKER_REGISTRY}/dyo:$(image_version) -f cli/Dockerfile . + docker buildx build --build-arg REVISION=${ORG_GOLANG_HASH} --platform=linux/amd64,linux/arm64 --push -t ${ORG_REGISTRY}/cli/dyo:$(image_version) -t ${DOCKER_REGISTRY}/dyo:$(image_version) -f cli/Dockerfile . .PHONY: build-dagent build-dagent: compile-dagent cd build && \ - docker buildx build --build-arg AGENT_BINARY=dagent --build-arg REVISION=$$(git rev-parse --short HEAD) --platform=linux/amd64 --load -t ${AGENT_REGISTRY_URL}/dagent:$(image_version) -t ${DOCKER_REGISTRY}/dagent:$(image_version) . + docker buildx build --build-arg AGENT_BINARY=dagent --build-arg REVISION=${ORG_GOLANG_HASH} --platform=linux/amd64 --load -t ${AGENT_REGISTRY_URL}/dagent:$(image_version) -t ${DOCKER_REGISTRY}/dagent:$(image_version) . .PHONY: build-crane build-crane: compile-crane cd build && \ - docker buildx build --build-arg AGENT_BINARY=crane --build-arg REVISION=$$(git rev-parse --short HEAD) --platform=linux/amd64 --load -t ${AGENT_REGISTRY_URL}/crane:$(image_version) -t ${DOCKER_REGISTRY}/crane:$(image_version) . + docker buildx build --build-arg AGENT_BINARY=crane --build-arg REVISION=${ORG_GOLANG_HASH} --platform=linux/amd64 --load -t ${AGENT_REGISTRY_URL}/crane:$(image_version) -t ${DOCKER_REGISTRY}/crane:$(image_version) . PHONY: build-dagent-multi-push build-dagent-multi-push: compile-dagent cd build && \ - docker buildx build --build-arg AGENT_BINARY=dagent --build-arg REVISION=$$(git rev-parse --short HEAD) --platform=linux/amd64,linux/arm64 --push -t ${AGENT_REGISTRY_URL}/dagent:$(image_version) -t ${DOCKER_REGISTRY}/dagent:$(image_version) . + docker buildx build --build-arg AGENT_BINARY=dagent --build-arg REVISION=${ORG_GOLANG_HASH} --platform=linux/amd64,linux/arm64 --push -t ${AGENT_REGISTRY_URL}/dagent:$(image_version) -t ${DOCKER_REGISTRY}/dagent:$(image_version) . .PHONY: build-crane-multi-push build-crane-multi-push: compile-crane cd build && \ - docker buildx build --build-arg AGENT_BINARY=crane --build-arg REVISION=$$(git rev-parse --short HEAD) --platform=linux/amd64,linux/arm64 --push -t ${AGENT_REGISTRY_URL}/crane:$(image_version) -t ${DOCKER_REGISTRY}/crane:$(image_version) . + docker buildx build --build-arg AGENT_BINARY=crane --build-arg REVISION=${ORG_GOLANG_HASH} --platform=linux/amd64,linux/arm64 --push -t ${AGENT_REGISTRY_URL}/crane:$(image_version) -t ${DOCKER_REGISTRY}/crane:$(image_version) . .PHONY: cli-compile-build-push cbpcli: compile-cli build-cli push-cli diff --git a/golang/cmd/crane/.env.example b/golang/cmd/crane/.env.example index 3ebeeeec1..6a459da53 100644 --- a/golang/cmd/crane/.env.example +++ b/golang/cmd/crane/.env.example @@ -14,15 +14,21 @@ DEBUG_UPDATE_ALWAYS=false DEBUG_UPDATE_USE_CONTAINERS=true DEFAULT_REGISTRY=index.docker.io -# crane specific options -CRANE_GEN_TCP_INGRESS_MAP= -CRANE_IN_CLUSTER=true +# Crane specific options +# Put 'true' to use in-cluster auth +CRANE_IN_CLUSTER=false +# The duration amount that for a kubernetes API request to complete DEFAULT_KUBE_TIMEOUT=2m +# Field manager name FIELD_MANAGER_NAME=crane-dyrector-io +# Use 'Force: true' while deploying FORCE_ON_CONFLICTS=true +# The key/label name for audit purposes KEY_ISSUER=co.dyrector.io/issuer +# The "kubectl" configuration location KUBECONFIG= +# Timeouts used in tests, no effect on deployment TEST_TIMEOUT=15s +# For injecting SecretPrivateKey SECRET_NAME=dyrectorio-secret SECRET_NAMESPACE=dyrectorio -CRANE_IN_CLUSTER=false diff --git a/golang/cmd/dagent/.env.example b/golang/cmd/dagent/.env.example index cdaed7b4b..2e1bf130b 100644 --- a/golang/cmd/dagent/.env.example +++ b/golang/cmd/dagent/.env.example @@ -18,18 +18,30 @@ AGENT_CONTAINER_NAME=dagent DAGENT_IMAGE=ghcr.io/dyrector-io/dyrectorio/dagent DAGENT_NAME=dagent-go DAGENT_TAG=latest +# This should match the mount path that is +# the root of configurations and containers DATA_MOUNT_PATH=/srv/dagent DEFAULT_TAG=latest DEFAULT_TIMEOUT=5s GRPC_KEEPALIVE=60s +# Path of 'docker.sock' or other local/remote +# address where we can communicate with docker HOST_DOCKER_SOCK_PATH=/var/run/docker.sock +# Containers mount path default INTERNAL_MOUNT_PATH=/srv/dagent +# Loglines to skip if not defined on the request LOG_DEFAULT_SKIP=0 +# Loglines to take LOG_DEFAULT_TAKE=100 MIN_DOCKER_VERSION=20.10 +# E-mail address to use for dynamic certificate requests TRAEFIK_ACME_MAIL= TRAEFIK_ENABLED=false +# Loglevel for Traefik +# Set to "DEBUG" to access Traefik dashboard TRAEFIK_LOG_LEVEL= +# Whether to enable Traefik TLS or not TRAEFIK_TLS=false DEFAULT_REGISTRY=index.docker.io +# Token used by the webhook to trigger the update WEBHOOK_TOKEN= diff --git a/web/crux-ui/.env.example b/web/crux-ui/.env.example index 9caeb2729..8f1b4e5d0 100644 --- a/web/crux-ui/.env.example +++ b/web/crux-ui/.env.example @@ -3,20 +3,28 @@ CRUX_UI_URL=http://localhost:8000 KRATOS_URL=http://localhost:8000/kratos KRATOS_ADMIN_URL=http://localhost:4434 +# Sets the severity level of logging +# Possible values: trace, debug, info, warn, error, and fatal +# The settings come in a hierarchic order +# Example: error contains fatal LOG_LEVEL=trace -# Google recaptcha config +## Google ReCAPTCHA config + DISABLE_RECAPTCHA=true -# required only when rechaptcha is enabled +# Required only when ReCAPTCHA is enabled RECAPTCHA_SITE_KEY= RECAPTCHA_SECRET_KEY= -# Playwright test config (for e2e tests) +## Playwright test config (for e2e tests) + E2E_BASE_URL=http://localhost:8000 # Docker HUB Proxy (optional) # HUB_PROXY_URL=http:// # HUB_PROXY_TOKEN= -# overriding the node dns result order regardless of the NODE_ENV value -#DNS_DEFAULT_RESULT_ORDER=ipv4first +# For overriding the node dns result order regardless of the NODE_ENV value +# It may be necessary for running the e2e tests, +# because node resolves localhost to IPv6 by default +# DNS_DEFAULT_RESULT_ORDER=ipv4first diff --git a/web/crux-ui/i18n.json b/web/crux-ui/i18n.json index 01a6e35f2..29938099f 100644 --- a/web/crux-ui/i18n.json +++ b/web/crux-ui/i18n.json @@ -22,7 +22,7 @@ "/[teamSlug]/projects/[projectId]/versions/[versionId]": ["versions", "images", "deployments"], "/[teamSlug]/projects/[projectId]/versions/[versionId]/images/[imageId]": ["images", "container"], "/[teamSlug]/nodes": ["nodes", "tokens"], - "/[teamSlug]/nodes/[nodeId]": ["nodes", "images", "tokens"], + "/[teamSlug]/nodes/[nodeId]": ["nodes", "images", "tokens", "deployments"], "/[teamSlug]/nodes/[nodeId]/log": [], "/[teamSlug]/registries": ["registries"], "/[teamSlug]/registries/[registryId]": ["registries"], diff --git a/web/crux-ui/locales/en/common.json b/web/crux-ui/locales/en/common.json index ca33e08d3..6ed679326 100644 --- a/web/crux-ui/locales/en/common.json +++ b/web/crux-ui/locales/en/common.json @@ -127,7 +127,8 @@ "nodeStatuses": { "connected": "Connected", "unreachable": "Unreachable", - "outdated": "Outdated" + "outdated": "Outdated", + "updating": "Updating" }, "deploymentStatuses": { diff --git a/web/crux-ui/locales/en/nodes.json b/web/crux-ui/locales/en/nodes.json index 9fba3e521..cf30dbf60 100644 --- a/web/crux-ui/locales/en/nodes.json +++ b/web/crux-ui/locales/en/nodes.json @@ -44,14 +44,11 @@ "shell": "Shell", "powershell": "PowerShell" }, - "statusFilters": { - "connected": "Connected", - "unreachable": "Unreachable" - }, "persistentDataPath": "Persistent data path", "persistentDataExplanation": "Stores basic operational data and serves as a base path for containers using relative volume names. Environment variables are resolved, but it is suggested to use absolute paths and avoid special characters.", "optionalLeaveEmptyForDefaults": "Optional, leave empty for default paths", "update": "Update", + "updateAvailable": "There is an update available for your node. You can update it with the update button below.", "updateRequired": "This agent is incompatible with the current version of dyrector.io. You can update it with the update button below.", "updateError": "Failed to update agent: {{error}}", "ports": "Ports (External -> Internal)", diff --git a/web/crux-ui/src/components/nodes/edit-node-section.tsx b/web/crux-ui/src/components/nodes/edit-node-section.tsx index ff8d1bdb3..5c9d3454e 100644 --- a/web/crux-ui/src/components/nodes/edit-node-section.tsx +++ b/web/crux-ui/src/components/nodes/edit-node-section.tsx @@ -11,7 +11,6 @@ import { NodeDetails, NodeEventMessage, NodeInstall, - nodeIsUpdateable, NodeType, UpdateNodeAgentMessage, WS_TYPE_NODE_EVENT, @@ -78,7 +77,6 @@ const EditNodeSection = (props: EditNodeSectionProps) => { address: message.address ?? node.address, status: message.status, hasToken: message.status === 'connected' || node.hasToken, - updating: message.updating ?? node.updating, install: message.status === 'connected' ? null : node.install, } as NodeDetails @@ -140,7 +138,7 @@ const EditNodeSection = (props: EditNodeSectionProps) => { setNode({ ...node, - updating: true, + status: 'updating', }) } @@ -160,7 +158,9 @@ const EditNodeSection = (props: EditNodeSectionProps) => { {t('agentSettings')} - {node.status === 'outdated' && {t('updateRequired')}} + {node.updatable && ( + {t(node.status === 'outdated' ? 'updateRequired' : 'updateAvailable')} + )}
{node.hasToken && ( @@ -174,11 +174,11 @@ const EditNodeSection = (props: EditNodeSectionProps) => { secondary danger={node.status === 'outdated'} onClick={onUpdateNode} - disabled={!nodeIsUpdateable(node)} + disabled={!node.updatable} > {t('update')} - {node.updating && } + {node.status === 'updating' && }
diff --git a/web/crux-ui/src/components/nodes/node-connection-card.tsx b/web/crux-ui/src/components/nodes/node-connection-card.tsx index 1cb582100..c7d978842 100644 --- a/web/crux-ui/src/components/nodes/node-connection-card.tsx +++ b/web/crux-ui/src/components/nodes/node-connection-card.tsx @@ -58,7 +58,7 @@ const NodeConnectionCard = (props: NodeConnectionCardProps) => { {t('uptime')} {runningSince ? : null} - {node.updating && ( + {node.status === 'updating' && ( <> {t('update')} {t('in-progress')} diff --git a/web/crux-ui/src/components/nodes/node-deployment-list.tsx b/web/crux-ui/src/components/nodes/node-deployment-list.tsx new file mode 100644 index 000000000..b3416108b --- /dev/null +++ b/web/crux-ui/src/components/nodes/node-deployment-list.tsx @@ -0,0 +1,177 @@ +import DeploymentStatusTag from '@app/components/projects/versions/deployments/deployment-status-tag' +import Filters from '@app/components/shared/filters' +import { DyoCard } from '@app/elements/dyo-card' +import DyoFilterChips from '@app/elements/dyo-filter-chips' +import { DyoHeading } from '@app/elements/dyo-heading' +import DyoIcon from '@app/elements/dyo-icon' +import { DyoList } from '@app/elements/dyo-list' +import DyoModal from '@app/elements/dyo-modal' +import { EnumFilter, enumFilterFor, TextFilter, textFilterFor, useFilters } from '@app/hooks/use-filters' +import { auditFieldGetter, dateSort, enumSort, sortHeaderBuilder, stringSort, useSorting } from '@app/hooks/use-sorting' +import useTeamRoutes from '@app/hooks/use-team-routes' +import { Deployment, DeploymentStatus, DEPLOYMENT_STATUS_VALUES } from '@app/models' +import { auditToLocaleDate } from '@app/utils' +import clsx from 'clsx' +import useTranslation from 'next-translate/useTranslation' +import Link from 'next/link' +import { useRouter } from 'next/router' +import { useState } from 'react' + +interface NodeDeploymentListProps { + deployments: Deployment[] +} + +type DeploymentFilter = TextFilter & EnumFilter +type DeploymentSorting = 'project' | 'version' | 'prefix' | 'updatedAt' | 'status' + +const NodeDeploymentList = (props: NodeDeploymentListProps) => { + const { deployments: propsDeployments } = props + + const { t } = useTranslation('deployments') + const routes = useTeamRoutes() + const router = useRouter() + + const [showInfo, setShowInfo] = useState(null) + + const filters = useFilters({ + filters: [ + textFilterFor(it => [it.project.name, it.version.name, it.prefix]), + enumFilterFor(it => [it.status]), + ], + initialData: propsDeployments, + }) + + const sorting = useSorting(filters.filtered, { + initialField: 'updatedAt', + initialDirection: 'asc', + sortFunctions: { + project: stringSort, + version: stringSort, + prefix: stringSort, + updatedAt: dateSort, + status: enumSort(DEPLOYMENT_STATUS_VALUES), + }, + fieldGetters: { + project: it => it.project.name, + version: it => it.version.name, + updatedAt: auditFieldGetter, + }, + }) + + const headers = [ + 'common:project', + 'common:version', + 'common:prefix', + 'common:updatedAt', + 'common:status', + 'common:actions', + ] + const defaultHeaderClass = 'h-11 uppercase text-bright text-sm bg-medium-eased py-3 px-2 font-semibold' + const headerClasses = [ + clsx('rounded-tl-lg pl-6', defaultHeaderClass), + ...Array.from({ length: headers.length - 3 }).map(() => defaultHeaderClass), + clsx('text-center', defaultHeaderClass), + clsx('rounded-tr-lg pr-6 text-center', defaultHeaderClass), + ] + + const defaultItemClass = 'h-11 min-h-min text-light-eased p-2 w-fit' + const itemClasses = [ + clsx('pl-6', defaultItemClass), + ...Array.from({ length: headerClasses.length - 3 }).map(() => defaultItemClass), + clsx('text-center', defaultItemClass), + clsx('pr-6 text-center', defaultItemClass), + ] + + const onCellClick = async (data: Deployment, row: number, col: number) => { + if (col >= headers.length - 1) { + return + } + + await router.push(routes.deployment.details(data.id)) + } + + const itemTemplate = (item: Deployment) => /* eslint-disable react/jsx-key */ [ + item.project.name, + item.version.name, + {item.prefix}, + {auditToLocaleDate(item.audit)}, + , + <> +
+ + + +
+ + 0 ? 'cursor-pointer' : 'cursor-not-allowed opacity-30'} + onClick={() => !!item.note && item.note.length > 0 && setShowInfo(item)} + /> + , + ] + /* eslint-enable react/jsx-key */ + + return ( + <> + {propsDeployments.length ? ( + <> + filters.setFilter({ text: it })}> + t(`common:deploymentStatuses.${it}`)} + selection={filters.filter?.enum} + onSelectionChange={type => { + filters.setFilter({ + enum: type, + }) + }} + /> + + + ( + sorting, + { + 'common:project': 'project', + 'common:version': 'version', + 'common:prefix': 'prefix', + 'common:updatedAt': 'updatedAt', + 'common:status': 'status', + }, + text => t(text), + )} + cellClick={onCellClick} + /> + + + ) : ( + + {t('noItems')} + + )} + {!showInfo ? null : ( + setShowInfo(null)} + > +

{showInfo.note}

+
+ )} + + ) +} + +export default NodeDeploymentList diff --git a/web/crux-ui/src/components/nodes/node-sections-heading.tsx b/web/crux-ui/src/components/nodes/node-sections-heading.tsx index efa857c68..66bedf997 100644 --- a/web/crux-ui/src/components/nodes/node-sections-heading.tsx +++ b/web/crux-ui/src/components/nodes/node-sections-heading.tsx @@ -30,11 +30,22 @@ const NodeSectionsHeading = (props: NodeSectionsHeadingProps) => { thin underlined={section === 'logs'} textColor="text-bright" - className="ml-6" + className="mx-6" onClick={() => setSection('logs')} > {t('logs')} + + setSection('deployments')} + > + {t('common:deployments')} + ) } diff --git a/web/crux-ui/src/components/nodes/use-node-details-state.tsx b/web/crux-ui/src/components/nodes/use-node-details-state.tsx index 7852e009c..77983020b 100644 --- a/web/crux-ui/src/components/nodes/use-node-details-state.tsx +++ b/web/crux-ui/src/components/nodes/use-node-details-state.tsx @@ -24,7 +24,7 @@ import { useEffect, useState } from 'react' import { PaginationSettings } from '../shared/paginator' import useNodeState from './use-node-state' -export type NodeDetailsSection = 'editing' | 'containers' | 'logs' +export type NodeDetailsSection = 'editing' | 'containers' | 'logs' | 'deployments' export type ContainerTargetStates = { [key: string]: ContainerState } // containerName to targetState diff --git a/web/crux-ui/src/components/projects/project-type-tag.tsx b/web/crux-ui/src/components/projects/project-type-tag.tsx index a02f9b913..4d04128a2 100644 --- a/web/crux-ui/src/components/projects/project-type-tag.tsx +++ b/web/crux-ui/src/components/projects/project-type-tag.tsx @@ -12,7 +12,7 @@ const ProjectTypeTag = (props: ProjectTypeTagProps) => { const { t } = useTranslation('projects') - return type === 'versionless' ? null : ( + return ( {t(type).toUpperCase()} diff --git a/web/crux-ui/src/components/projects/versions/deployments/deployment-container-status-list.tsx b/web/crux-ui/src/components/projects/versions/deployments/deployment-container-status-list.tsx index ec2ec665b..481b45725 100644 --- a/web/crux-ui/src/components/projects/versions/deployments/deployment-container-status-list.tsx +++ b/web/crux-ui/src/components/projects/versions/deployments/deployment-container-status-list.tsx @@ -1,7 +1,9 @@ import ContainerStatusIndicator from '@app/components/nodes/container-status-indicator' import ContainerStatusTag from '@app/components/nodes/container-status-tag' +import { SECOND_IN_MILLIS } from '@app/const' import DyoIcon from '@app/elements/dyo-icon' import { DyoList } from '@app/elements/dyo-list' +import useInterval from '@app/hooks/use-interval' import useTeamRoutes from '@app/hooks/use-team-routes' import useWebSocket from '@app/hooks/use-websocket' import { @@ -50,6 +52,10 @@ const DeploymentContainerStatusList = (props: DeploymentContainerStatusListProps })), ) + useInterval(() => { + setContainers([...containers]) + }, SECOND_IN_MILLIS) + const sock = useWebSocket(routes.node.detailsSocket(deployment.node.id), { onOpen: () => sock.send(WS_TYPE_WATCH_CONTAINERS_STATE, { diff --git a/web/crux-ui/src/components/projects/versions/deployments/use-deployment-state.tsx b/web/crux-ui/src/components/projects/versions/deployments/use-deployment-state.tsx index 6154fa88a..3266c38cf 100644 --- a/web/crux-ui/src/components/projects/versions/deployments/use-deployment-state.tsx +++ b/web/crux-ui/src/components/projects/versions/deployments/use-deployment-state.tsx @@ -135,7 +135,6 @@ const useDeploymentState = (options: DeploymentStateOptions): [DeploymentState, ...node, status: message.status, address: message.address, - updating: message.updating ?? node.updating, }) }) diff --git a/web/crux-ui/src/models/node.ts b/web/crux-ui/src/models/node.ts index 0eceba727..8a58b60d4 100644 --- a/web/crux-ui/src/models/node.ts +++ b/web/crux-ui/src/models/node.ts @@ -7,7 +7,7 @@ export type NodeType = (typeof NODE_TYPE_VALUES)[number] export const NODE_INSTALL_SCRIPT_TYPE_VALUES = ['shell', 'powershell'] as const export type NodeInstallScriptType = (typeof NODE_INSTALL_SCRIPT_TYPE_VALUES)[number] -export const NODE_STATUS_VALUES = ['unreachable', 'connected', 'outdated'] as const +export const NODE_STATUS_VALUES = ['unreachable', 'connected', 'outdated', 'updating'] as const export type NodeStatus = (typeof NODE_STATUS_VALUES)[number] export const NODE_EVENT_TYPE_VALUES = [ @@ -34,7 +34,6 @@ export type DyoNode = NodeConnection & { description?: string icon?: string type: NodeType - updating: boolean } export type NodeInstall = { @@ -47,6 +46,7 @@ export type NodeDetails = DyoNode & { hasToken: boolean install?: NodeInstall lastConnectionAt?: string + updatable: boolean inUse: boolean } @@ -106,7 +106,6 @@ export type NodeEventMessage = { version?: string connectedAt?: string error?: string - updating?: boolean } export const WS_TYPE_WATCH_CONTAINERS_STATE = 'watch-containers-state' @@ -142,6 +141,3 @@ export type UpdateNodeAgentMessage = { export const WS_TYPE_CONTAINER_COMMAND = 'container-command' export type ContainerCommandMessage = ContainerCommand - -export const nodeIsUpdateable = (node: NodeDetails) => - (node.status === 'connected' || node.status === 'outdated') && !node.updating diff --git a/web/crux-ui/src/pages/[teamSlug]/nodes.tsx b/web/crux-ui/src/pages/[teamSlug]/nodes.tsx index ccbf41c4b..1ce711cdd 100644 --- a/web/crux-ui/src/pages/[teamSlug]/nodes.tsx +++ b/web/crux-ui/src/pages/[teamSlug]/nodes.tsx @@ -11,7 +11,7 @@ import DyoWrap from '@app/elements/dyo-wrap' import { EnumFilter, enumFilterFor, TextFilter, textFilterFor, useFilters } from '@app/hooks/use-filters' import useTeamRoutes from '@app/hooks/use-team-routes' import useWebSocket from '@app/hooks/use-websocket' -import { DyoNode, NodeEventMessage, NodeStatus, WS_TYPE_NODE_EVENT } from '@app/models' +import { DyoNode, NODE_STATUS_VALUES, NodeEventMessage, NodeStatus, WS_TYPE_NODE_EVENT } from '@app/models' import { ROUTE_DOCS, TeamRoutes } from '@app/routes' import { withContextAuthorization } from '@app/utils' import { getCruxFromContext } from '@server/crux-api' @@ -27,8 +27,6 @@ interface NodesPageProps { nodes: DyoNode[] } -const nodeStatusFilters = ['connected', 'unreachable'] as const - type NodeFilter = TextFilter & EnumFilter const NodesPage = (props: NodesPageProps) => { @@ -109,8 +107,8 @@ const NodesPage = (props: NodesPageProps) => { filters.setFilter({ text: it })}> t(`statusFilters.${it}`)} + choices={NODE_STATUS_VALUES} + converter={it => t(`common:nodeStatuses.${it}`)} selection={filters.filter?.enum} onSelectionChange={type => { filters.setFilter({ diff --git a/web/crux-ui/src/pages/[teamSlug]/nodes/[nodeId].tsx b/web/crux-ui/src/pages/[teamSlug]/nodes/[nodeId].tsx index d2aec3c5c..b7e17aef2 100644 --- a/web/crux-ui/src/pages/[teamSlug]/nodes/[nodeId].tsx +++ b/web/crux-ui/src/pages/[teamSlug]/nodes/[nodeId].tsx @@ -4,6 +4,7 @@ import EditNodeSection from '@app/components/nodes/edit-node-section' import NodeAuditList from '@app/components/nodes/node-audit-list' import NodeConnectionCard from '@app/components/nodes/node-connection-card' import NodeContainersList from '@app/components/nodes/node-containers-list' +import NodeDeploymentList from '@app/components/nodes/node-deployment-list' import NodeSectionsHeading from '@app/components/nodes/node-sections-heading' import useNodeDetailsState from '@app/components/nodes/use-node-details-state' import { BreadcrumbLink } from '@app/components/shared/breadcrumb' @@ -13,7 +14,7 @@ import { DetailsPageMenu } from '@app/components/shared/page-menu' import { DyoConfirmationModal } from '@app/elements/dyo-modal' import { defaultApiErrorHandler } from '@app/errors' import useTeamRoutes from '@app/hooks/use-team-routes' -import { NodeDetails } from '@app/models' +import { Deployment, NodeDetails } from '@app/models' import { TeamRoutes } from '@app/routes' import { withContextAuthorization } from '@app/utils' import { getCruxFromContext } from '@server/crux-api' @@ -25,10 +26,11 @@ import { useSWRConfig } from 'swr' interface NodeDetailsPageProps { node: NodeDetails + deployments: Deployment[] } const NodeDetailsPage = (props: NodeDetailsPageProps) => { - const { node: propsNode } = props + const { node: propsNode, deployments } = props const { t } = useTranslation('nodes') const routes = useTeamRoutes() @@ -113,8 +115,10 @@ const NodeDetailsPage = (props: NodeDetailsPageProps) => { - ) : ( + ) : state.section === 'logs' ? ( + ) : ( + )} )} @@ -132,10 +136,12 @@ const getPageServerSideProps = async (context: NextPageContext) => { const nodeId = context.query.nodeId as string const node = await getCruxFromContext(context, routes.node.api.details(nodeId)) + const deployments = await getCruxFromContext(context, routes.node.api.deployments(nodeId)) return { props: { node, + deployments, }, } } diff --git a/web/crux-ui/src/routes.ts b/web/crux-ui/src/routes.ts index 673e48612..b6a9a5684 100644 --- a/web/crux-ui/src/routes.ts +++ b/web/crux-ui/src/routes.ts @@ -201,6 +201,8 @@ class NodeApi { audit = (id: string, query: AuditLogQuery) => urlQuery(`${this.details(id)}/audit`, query) + deployments = (id: string) => `${this.details(id)}/deployments` + // node-global-container globalContainerList = (id: string) => `${this.details(id)}/containers` diff --git a/web/crux/.env.example b/web/crux/.env.example index 4a8fc580f..a4785dbda 100644 --- a/web/crux/.env.example +++ b/web/crux/.env.example @@ -1,39 +1,58 @@ NODE_ENV=development -# Application configs -KRATOS_URL=http://localhost:4433 -KRATOS_ADMIN_URL=http://localhost:4434 +## Development configurations + +# Kratos public API KRATOS_URL=http://localhost:8000/kratos -DATABASE_URL="postgresql://crux:crux@localhost:5432/crux?schema=public" +# Kratos admin API +# This should never be exposed +KRATOS_ADMIN_URL=http://localhost:4434 +DATABASE_URL="postgresql://username:password@localhost:5432/crux?schema=public" CRUX_UI_URL=http://localhost:8000 -# Port settings + +## Port settings + +# Agent gRPC API port GRPC_AGENT_PORT=5000 +# RestAPI port HTTP_API_PORT=1848 +# Prometheus metrics port METRICS_API_PORT=1956 # Podman has different alias host.containers.local:5000 -# for local development replace this with localhost:5000 -CRUX_AGENT_ADDRESS=host.docker.internal:5000 +CRUX_AGENT_ADDRESS=localhost:5000 +# Signing secret for the generated JWTs JWT_SECRET=jwt-secret-token -# Uncomment to use a specific agent version -# CRUX_AGENT_IMAGE=stable +# The Docker image tag in the node install script +# Uncomment to use a different agent version +# Defaults to the version of dyrector.io +# CRUX_AGENT_IMAGE=latest -# Install script docker pull disabled for the E2E tests -AGENT_INSTALL_SCRIPT_DISABLE_PULL=true +# Uncomment to prevent the install script from +# overwriting your locally built agent image +# AGENT_INSTALL_SCRIPT_DISABLE_PULL=true # Possible values: trace, debug, info, warn, error, and fatal +# The settings above come in a hierarchic order +# Example: error contains fatal LOG_LEVEL=debug -# Email service config +## Email service config +# SMTP URL for the mailslurper SMTP_URI=smtps://test:test@localhost:1025/?skip_ssl_verify=true&legacy_ssl=true -FROM_EMAIL=noreply@dyrector.io -FROM_NAME="dyrector.io Platform" +# E-mail address for dyrector.io invitation links, password resets and others +FROM_EMAIL=from@example.com +# E-mail sender name for dyrector.io invitation links, password resets and others +FROM_NAME=dyrector.io -# Google recaptcha config +## Google ReCAPTCHA config DISABLE_RECAPTCHA=true -# required only when rechaptcha is enabled +# Required only when ReCAPTCHA is enabled RECAPTCHA_SECRET_KEY= -# overriding the node dns result order regardless of the NODE_ENV value +# For overriding the node DNS result order +# regardless of the NODE_ENV value +# It may be necessary for running the e2e tests, +# because node resolves localhost to IPv6 by default # DNS_DEFAULT_RESULT_ORDER=ipv4first diff --git a/web/crux/src/app/agent/agent.service.ts b/web/crux/src/app/agent/agent.service.ts index 8d82ce732..da92d95fe 100644 --- a/web/crux/src/app/agent/agent.service.ts +++ b/web/crux/src/app/agent/agent.service.ts @@ -17,7 +17,7 @@ import { startWith, takeUntil, } from 'rxjs' -import { coerce } from 'semver' +import { SemVer, coerce } from 'semver' import { Agent, AgentConnectionMessage, AgentTokenReplacement } from 'src/domain/agent' import AgentInstaller from 'src/domain/agent-installer' import { generateAgentToken } from 'src/domain/agent-token' @@ -380,11 +380,7 @@ export default class AgentService { } agentVersionSupported(version: string): boolean { - if (!version.includes('-')) { - return false - } - - const agentVersion = coerce(version) + const agentVersion = this.getAgentSemVer(version) if (!agentVersion) { return false } @@ -397,6 +393,17 @@ export default class AgentService { ) } + agentVersionIsUpToDate(version: string): boolean { + const agentVersion = this.getAgentSemVer(version) + if (!agentVersion) { + return false + } + + const packageVersion = coerce(getPackageVersion(this.configService)) + + return agentVersion.compare(packageVersion) === 0 + } + generateConnectionTokenFor(nodeId: string, startedBy: string): AgentTokenReplacement { const token = generateAgentToken(nodeId, 'connection') const signedToken = this.jwtService.sign(token) @@ -408,6 +415,19 @@ export default class AgentService { } } + private getAgentSemVer(version: string): SemVer | null { + if (!version.includes('-')) { + return null + } + + const semver = coerce(version) + if (!semver) { + return null + } + + return semver + } + private async onAgentConnectionStatusChange(agent: Agent, status: NodeConnectionStatus) { if (status === 'unreachable') { const storedAgent = this.agents.get(agent.id) diff --git a/web/crux/src/app/agent/connection-strategies/agent.connection.legacy.strategy.ts b/web/crux/src/app/agent/connection-strategies/agent.connection.legacy.strategy.ts index 2d88eb26f..01ebef4e7 100644 --- a/web/crux/src/app/agent/connection-strategies/agent.connection.legacy.strategy.ts +++ b/web/crux/src/app/agent/connection-strategies/agent.connection.legacy.strategy.ts @@ -53,7 +53,7 @@ export default class AgentConnectionLegacyStrategy extends AgentConnectionStrate }) } - // this legacy token is already replaced or + // this legacy token is already replaced // we send a shutdown to the incoming agent info.id = AgentConnectionLegacyStrategy.LEGACY_NONCE const legacyToken = generateAgentToken(AgentConnectionLegacyStrategy.LEGACY_NONCE, 'install') diff --git a/web/crux/src/app/deploy/deploy.service.ts b/web/crux/src/app/deploy/deploy.service.ts index 6e23be2fe..fcfc4be49 100644 --- a/web/crux/src/app/deploy/deploy.service.ts +++ b/web/crux/src/app/deploy/deploy.service.ts @@ -833,7 +833,7 @@ export default class DeployService { return this.deploymentImageEvents.pipe(filter(it => it.deploymentIds.includes(deploymentId))) } - async getDeployments(teamSlug: string): Promise { + async getDeployments(teamSlug: string, nodeId?: string): Promise { const deployments = await this.prisma.deployment.findMany({ where: { version: { @@ -843,6 +843,7 @@ export default class DeployService { }, }, }, + nodeId, }, include: { version: { diff --git a/web/crux/src/app/deploy/interceptors/deploy.start.interceptor.ts b/web/crux/src/app/deploy/interceptors/deploy.start.interceptor.ts index a8bdaf7d9..39195dfb1 100644 --- a/web/crux/src/app/deploy/interceptors/deploy.start.interceptor.ts +++ b/web/crux/src/app/deploy/interceptors/deploy.start.interceptor.ts @@ -91,9 +91,9 @@ export default class DeployStartValidationInterceptor implements NestInterceptor yupValidate(startDeploymentSchema, target) const node = this.agentService.getById(deployment.nodeId) - if (!node?.connected) { + if (!node?.ready) { throw new CruxPreconditionFailedException({ - message: 'Node is unreachable', + message: 'Node is busy or unreachable', property: 'nodeId', value: deployment.nodeId, }) diff --git a/web/crux/src/app/node/node.dto.ts b/web/crux/src/app/node/node.dto.ts index 71d794cb8..9b0ccfd85 100644 --- a/web/crux/src/app/node/node.dto.ts +++ b/web/crux/src/app/node/node.dto.ts @@ -18,7 +18,7 @@ import { ContainerIdentifierDto } from '../container/container.dto' export const NODE_SCRIPT_TYPE_VALUES = ['shell', 'powershell'] as const export type NodeScriptTypeDto = (typeof NODE_SCRIPT_TYPE_VALUES)[number] -export const NODE_CONNECTION_STATUS_VALUES = ['unreachable', 'connected', 'outdated'] as const +export const NODE_CONNECTION_STATUS_VALUES = ['unreachable', 'connected', 'outdated', 'updating'] as const export type NodeConnectionStatus = (typeof NODE_CONNECTION_STATUS_VALUES)[number] export const NODE_TYPE_VALUES = ['docker', 'k8s'] as const @@ -78,10 +78,6 @@ export class NodeDto extends BasicNodeDto { @IsString() @IsOptional() version?: string - - @IsBoolean() - @IsOptional() - updating?: boolean } export class NodeInstallDto { @@ -104,6 +100,10 @@ export class NodeDetailsDto extends NodeDto { @ValidateNested() install?: NodeInstallDto + @IsBoolean() + @IsOptional() + updatable?: boolean + @IsBoolean() inUse: boolean } diff --git a/web/crux/src/app/node/node.http.controller.ts b/web/crux/src/app/node/node.http.controller.ts index d0cf6af08..cd7932b88 100644 --- a/web/crux/src/app/node/node.http.controller.ts +++ b/web/crux/src/app/node/node.http.controller.ts @@ -27,6 +27,8 @@ import { import { Identity } from '@ory/kratos-client' import UuidParams from 'src/decorators/api-params.decorator' import { CreatedResponse, CreatedWithLocation } from '../../interceptors/created-with-location.decorator' +import { DeploymentDto } from '../deploy/deploy.dto' +import DeployService from '../deploy/deploy.service' import { DisableAuth, IdentityFromRequest } from '../token/jwt-auth.guard' import NodeTeamAccessGuard from './guards/node.team-access.http.guard' import { NodeId, PARAM_NODE_ID, ROUTE_NODES, ROUTE_NODE_ID, ROUTE_TEAM_SLUG, TeamSlug } from './node.const' @@ -49,13 +51,16 @@ import NodeGetScriptValidationPipe from './pipes/node.get-script.pipe' @ApiTags(ROUTE_NODES) @UseGuards(NodeTeamAccessGuard) export default class NodeHttpController { - constructor(private service: NodeService) {} + constructor( + private service: NodeService, + private deployService: DeployService, + ) {} @Get() @HttpCode(HttpStatus.OK) @ApiOperation({ description: - "Fetch data of deployment targets. Request must include `teamSlug` in URL. Response should include an array with the node's `type`, `status`, `description`, `icon`, `address`, `connectedAt` date, `version`, `updating`, `id`, and `name`.", + "Fetch data of deployment targets. Request must include `teamSlug` in URL. Response should include an array with the node's `type`, `status`, `description`, `icon`, `address`, `connectedAt` date, `version`, `id`, and `name`.", summary: 'Get data of nodes that belong to your team.', }) @ApiOkResponse({ @@ -72,7 +77,7 @@ export default class NodeHttpController { @HttpCode(HttpStatus.OK) @ApiOperation({ description: - "Fetch data of a specific node. Request must include `teamSlug` in URL, and `nodeId` in body. Response should include an array with the node's `type`, `status`, `description`, `icon`, `address`, `connectedAt` date, `version`, `updating`, `id`, `name`, `hasToken`, and agent installation details.", + "Fetch data of a specific node. Request must include `teamSlug` in URL, and `nodeId` in body. Response should include an array with the node's `type`, `status`, `description`, `icon`, `address`, `connectedAt` date, `version`, `updatable`, `id`, `name`, `hasToken`, and agent installation details.", summary: 'Get data of nodes that belong to your team.', }) @ApiOkResponse({ type: NodeDetailsDto, description: 'Data of the node.' }) @@ -88,7 +93,7 @@ export default class NodeHttpController { @HttpCode(HttpStatus.CREATED) @ApiOperation({ description: - "Request must include the `teamSlug` in URL, and node's `name` in body. Response should include an array with the node's `type`, `status`, `description`, `icon`, `address`, `connectedAt` date, `version`, `updating`, `id`, and `name`.", + "Request must include the `teamSlug` in URL, and node's `name` in body. Response should include an array with the node's `type`, `status`, `description`, `icon`, `address`, `connectedAt` date, `version`, `id`, and `name`.", summary: 'Create new node.', }) @CreatedWithLocation() @@ -187,7 +192,7 @@ export default class NodeHttpController { @ApiProduces('text/plain') @ApiOperation({ description: - "Request must include the `teamSlug` in URL, and node's `name` in body. Response should include `type`, `status`, `description`, `icon`, `address`, `connectedAt` date, `version`, `updating`, `id`, `name`, `hasToken`, and `install` details.", + "Request must include the `teamSlug` in URL, and node's `name` in body. Response should include `type`, `status`, `description`, `icon`, `address`, `connectedAt` date, `version`, `updatable`, `id`, `name`, `hasToken`, and `install` details.", summary: 'Fetch install script.', }) @ApiOkResponse({ type: NodeDetailsDto, description: 'Install script.' }) @@ -251,4 +256,21 @@ export default class NodeHttpController { ): Promise { return await this.service.getAuditLog(nodeId, query) } + + @Get(`${ROUTE_NODE_ID}/deployments`) + @HttpCode(HttpStatus.OK) + @ApiOperation({ + description: + 'Get the list of deployments. Request needs to include `teamSlug` in URL. A deployment should include `id`, `prefix`, `status`, `note`, `audit` log details, project `name`, `id`, `type`, version `name`, `type`, `id`, and node `name`, `id`, `type`.', + summary: 'Fetch the list of deployments.', + }) + @ApiOkResponse({ + type: DeploymentDto, + isArray: true, + description: 'List of deployments.', + }) + @ApiForbiddenResponse({ description: 'Unauthorized request for deployments.' }) + async getDeployments(@TeamSlug() teamSlug: string, @NodeId() nodeId: string): Promise { + return await this.deployService.getDeployments(teamSlug, nodeId) + } } diff --git a/web/crux/src/app/node/node.mapper.ts b/web/crux/src/app/node/node.mapper.ts index 4011a18af..cddd14ba7 100644 --- a/web/crux/src/app/node/node.mapper.ts +++ b/web/crux/src/app/node/node.mapper.ts @@ -65,20 +65,22 @@ export default class NodeMapper { return { id: node.id, address: agent?.address, - status: agent?.outdated ? 'outdated' : agent?.getConnectionStatus() ?? 'unreachable', + status: agent?.getConnectionStatus() ?? 'unreachable', connectedAt: node.connectedAt ?? null, version: agent?.version, - updating: agent?.updating, } } detailsToDto(node: NodeDetails): NodeDetailsDto { const installer = this.agentService.getInstallerByNodeId(node.id) + const agent = this.agentService.getById(node.id) + return { ...this.toDto(node), hasToken: !!node.token, install: installer ? this.installerToDto(installer) : null, + updatable: agent && (agent.outdated || !this.agentService.agentVersionIsUpToDate(agent.version)), inUse: node._count.deployments > 0, } } diff --git a/web/crux/src/app/node/node.message.ts b/web/crux/src/app/node/node.message.ts index a59308ec8..9d9fe3175 100644 --- a/web/crux/src/app/node/node.message.ts +++ b/web/crux/src/app/node/node.message.ts @@ -18,8 +18,6 @@ export class NodeEventMessage { connectedAt?: Date error?: string - - updating?: boolean } export type UpdateNodeMessage = { diff --git a/web/crux/src/app/node/node.module.ts b/web/crux/src/app/node/node.module.ts index a54e77146..a495f88dd 100644 --- a/web/crux/src/app/node/node.module.ts +++ b/web/crux/src/app/node/node.module.ts @@ -6,6 +6,7 @@ import KratosService from 'src/services/kratos.service' import PrismaService from 'src/services/prisma.service' import AgentModule from '../agent/agent.module' import AuditLoggerModule from '../audit.logger/audit.logger.module' +import DeployModule from '../deploy/deploy.module' import TeamModule from '../team/team.module' import TeamRepository from '../team/team.repository' import NodeContainerWebSocketGateway from './node.container.ws.gateway' @@ -17,7 +18,7 @@ import NodeService from './node.service' import NodeWebSocketGateway from './node.ws.gateway' @Module({ - imports: [AgentModule, TeamModule, HttpModule, AuditLoggerModule], + imports: [AgentModule, TeamModule, HttpModule, AuditLoggerModule, DeployModule], exports: [NodeMapper], controllers: [NodeHttpController, NodePrefixContainerHttpController, NodeGlobalContainerHttpController], providers: [ diff --git a/web/crux/src/domain/agent.spec.ts b/web/crux/src/domain/agent.spec.ts index 5a6fe341f..693de9321 100644 --- a/web/crux/src/domain/agent.spec.ts +++ b/web/crux/src/domain/agent.spec.ts @@ -79,7 +79,6 @@ describe('agent', () => { status: 'connected', version: AGENT_VERSION, connectedAt: agentConnection.connectedAt, - updating: false, } expect(actual).toEqual(expected) }) @@ -99,7 +98,6 @@ describe('agent', () => { status: 'unreachable', version: null, connectedAt: null, - updating: false, } expect(actual).toEqual(expected) }) @@ -277,7 +275,6 @@ describe('agent', () => { id: AGENT_ID, status: 'connected', error: 'test', - updating: false, } expect(eventChannelActual).toEqual(expected) }) diff --git a/web/crux/src/domain/agent.ts b/web/crux/src/domain/agent.ts index 9d0557ee8..a911d57bd 100644 --- a/web/crux/src/domain/agent.ts +++ b/web/crux/src/domain/agent.ts @@ -67,6 +67,10 @@ export class Agent { readonly outdated: boolean + private get connected() { + return !this.commandChannel.closed + } + get id(): string { return this.connection.nodeId } @@ -83,7 +87,7 @@ export class Agent { return this.info.publicKey } - get connected() { + get ready(): boolean { return this.getConnectionStatus() === 'connected' } @@ -95,7 +99,19 @@ export class Agent { } getConnectionStatus(): NodeConnectionStatus { - return !this.commandChannel.closed ? 'connected' : 'unreachable' + if (!this.connected) { + return 'unreachable' + } + + if (this.updating) { + return 'updating' + } + + if (this.outdated) { + return 'outdated' + } + + return 'connected' } getDeployment(id: string): Deployment { @@ -225,10 +241,9 @@ export class Agent { this.eventChannel.next({ id: this.id, address: this.address, - status: this.outdated ? 'outdated' : 'connected', + status: this.getConnectionStatus(), version: this.version, connectedAt: this.connection.connectedAt, - updating: false, }) return this.commandChannel.asObservable() @@ -247,7 +262,6 @@ export class Agent { address: null, version: null, connectedAt: null, - updating: false, }) } @@ -379,9 +393,8 @@ export class Agent { this.eventChannel.next({ id: this.id, - status: this.outdated ? 'outdated' : 'connected', + status: this.getConnectionStatus(), error, - updating: false, }) } @@ -398,6 +411,12 @@ export class Agent { /* empty */ } + this.update = null + this.eventChannel.next({ + id: this.id, + status: this.getConnectionStatus(), + }) + return result } @@ -482,5 +501,4 @@ export type AgentConnectionMessage = { version?: string connectedAt?: Date error?: string - updating?: boolean }