From 2c56755e1f4dee14f58ebb9c5f51cd63dbe00ea5 Mon Sep 17 00:00:00 2001 From: Paschalis Tsilias Date: Mon, 26 Aug 2024 13:48:27 +0300 Subject: [PATCH] Display remote configuration components in Alloy's local UI (#1472) Signed-off-by: Paschalis Tsilias --- internal/service/ui/ui.go | 8 ++- internal/web/api/api.go | 39 +++++++------ internal/web/ui/src/Router.tsx | 6 ++ .../src/features/component/ComponentList.tsx | 6 +- .../src/features/component/ComponentView.tsx | 22 +++++--- .../web/ui/src/features/layout/Navbar.tsx | 5 ++ internal/web/ui/src/hooks/componentInfo.tsx | 13 ++++- .../web/ui/src/pages/ComponentDetailPage.tsx | 10 ++-- internal/web/ui/src/pages/Graph.tsx | 2 +- .../web/ui/src/pages/PageComponentList.tsx | 4 +- .../ui/src/pages/PageRemoteComponentList.tsx | 46 +++++++++++++++ .../src/pages/RemoteComponentDetailPage.tsx | 56 +++++++++++++++++++ 12 files changed, 180 insertions(+), 37 deletions(-) create mode 100644 internal/web/ui/src/pages/PageRemoteComponentList.tsx create mode 100644 internal/web/ui/src/pages/RemoteComponentDetailPage.tsx diff --git a/internal/service/ui/ui.go b/internal/service/ui/ui.go index c64cf5a9f3..ae462ddfe7 100644 --- a/internal/service/ui/ui.go +++ b/internal/service/ui/ui.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/alloy/internal/service" http_service "github.com/grafana/alloy/internal/service/http" "github.com/grafana/alloy/internal/service/livedebugging" + remotecfg_service "github.com/grafana/alloy/internal/service/remotecfg" "github.com/grafana/alloy/internal/web/api" "github.com/grafana/alloy/internal/web/ui" ) @@ -48,7 +49,7 @@ func (s *Service) Definition() service.Definition { return service.Definition{ Name: ServiceName, ConfigType: nil, // ui does not accept configuration - DependsOn: []string{http_service.ServiceName, livedebugging.ServiceName}, + DependsOn: []string{http_service.ServiceName, livedebugging.ServiceName, remotecfg_service.ServiceName}, Stability: featuregate.StabilityGenerallyAvailable, } } @@ -77,7 +78,10 @@ func (s *Service) Data() any { func (s *Service) ServiceHandler(host service.Host) (base string, handler http.Handler) { r := mux.NewRouter() - fa := api.NewAlloyAPI(host, s.opts.CallbackManager) + remotecfgSvc, _ := host.GetService(remotecfg_service.ServiceName) + remotecfgHost := remotecfgSvc.Data().(remotecfg_service.Data).Host + + fa := api.NewAlloyAPI(host, remotecfgHost, s.opts.CallbackManager) fa.RegisterRoutes(path.Join(s.opts.UIPrefix, "/api/v0/web"), r) ui.RegisterRoutes(s.opts.UIPrefix, r) diff --git a/internal/web/api/api.go b/internal/web/api/api.go index 024e6f1160..3c6688d04e 100644 --- a/internal/web/api/api.go +++ b/internal/web/api/api.go @@ -24,12 +24,13 @@ import ( // AlloyAPI is a wrapper around the component API. type AlloyAPI struct { alloy service.Host + remotecfg service.Host CallbackManager livedebugging.CallbackManager } // NewAlloyAPI instantiates a new Alloy API. -func NewAlloyAPI(alloy service.Host, CallbackManager livedebugging.CallbackManager) *AlloyAPI { - return &AlloyAPI{alloy: alloy, CallbackManager: CallbackManager} +func NewAlloyAPI(alloy, remotecfg service.Host, CallbackManager livedebugging.CallbackManager) *AlloyAPI { + return &AlloyAPI{alloy: alloy, remotecfg: remotecfg, CallbackManager: CallbackManager} } // RegisterRoutes registers all the API's routes. @@ -38,14 +39,20 @@ func (a *AlloyAPI) RegisterRoutes(urlPrefix string, r *mux.Router) { // id to contain / characters, which is used by nested module IDs and // component IDs. - r.Handle(path.Join(urlPrefix, "/modules/{moduleID:.+}/components"), httputil.CompressionHandler{Handler: a.listComponentsHandler()}) - r.Handle(path.Join(urlPrefix, "/components"), httputil.CompressionHandler{Handler: a.listComponentsHandler()}) - r.Handle(path.Join(urlPrefix, "/components/{id:.+}"), httputil.CompressionHandler{Handler: a.getComponentHandler()}) - r.Handle(path.Join(urlPrefix, "/peers"), httputil.CompressionHandler{Handler: a.getClusteringPeersHandler()}) - r.Handle(path.Join(urlPrefix, "/debug/{id:.+}"), a.liveDebugging()) + r.Handle(path.Join(urlPrefix, "/modules/{moduleID:.+}/components"), httputil.CompressionHandler{Handler: listComponentsHandler(a.alloy)}) + r.Handle(path.Join(urlPrefix, "/remotecfg/modules/{moduleID:.+}/components"), httputil.CompressionHandler{Handler: listComponentsHandler(a.remotecfg)}) + + r.Handle(path.Join(urlPrefix, "/components"), httputil.CompressionHandler{Handler: listComponentsHandler(a.alloy)}) + r.Handle(path.Join(urlPrefix, "/remotecfg/components"), httputil.CompressionHandler{Handler: listComponentsHandler(a.remotecfg)}) + + r.Handle(path.Join(urlPrefix, "/components/{id:.+}"), httputil.CompressionHandler{Handler: getComponentHandler(a.alloy)}) + r.Handle(path.Join(urlPrefix, "/remotecfg/components/{id:.+}"), httputil.CompressionHandler{Handler: getComponentHandler(a.remotecfg)}) + + r.Handle(path.Join(urlPrefix, "/peers"), httputil.CompressionHandler{Handler: getClusteringPeersHandler(a.alloy)}) + r.Handle(path.Join(urlPrefix, "/debug/{id:.+}"), liveDebugging(a.alloy, a.CallbackManager)) } -func (a *AlloyAPI) listComponentsHandler() http.HandlerFunc { +func listComponentsHandler(host service.Host) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // moduleID is set from the /modules/{moduleID:.+}/components route above // but not from the /components route. @@ -54,7 +61,7 @@ func (a *AlloyAPI) listComponentsHandler() http.HandlerFunc { moduleID = vars["moduleID"] } - components, err := a.alloy.ListComponents(moduleID, component.InfoOptions{ + components, err := host.ListComponents(moduleID, component.InfoOptions{ GetHealth: true, }) if err != nil { @@ -71,12 +78,12 @@ func (a *AlloyAPI) listComponentsHandler() http.HandlerFunc { } } -func (a *AlloyAPI) getComponentHandler() http.HandlerFunc { +func getComponentHandler(host service.Host) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) requestedComponent := component.ParseID(vars["id"]) - component, err := a.alloy.GetComponent(requestedComponent, component.InfoOptions{ + component, err := host.GetComponent(requestedComponent, component.InfoOptions{ GetHealth: true, GetArguments: true, GetExports: true, @@ -96,11 +103,11 @@ func (a *AlloyAPI) getComponentHandler() http.HandlerFunc { } } -func (a *AlloyAPI) getClusteringPeersHandler() http.HandlerFunc { +func getClusteringPeersHandler(host service.Host) http.HandlerFunc { return func(w http.ResponseWriter, _ *http.Request) { // TODO(@tpaschalis) Detect if clustering is disabled and propagate to // the Typescript code (eg. via the returned status code?). - svc, found := a.alloy.GetService(cluster.ServiceName) + svc, found := host.GetService(cluster.ServiceName) if !found { http.Error(w, "cluster service not running", http.StatusInternalServerError) return @@ -115,7 +122,7 @@ func (a *AlloyAPI) getClusteringPeersHandler() http.HandlerFunc { } } -func (a *AlloyAPI) liveDebugging() http.HandlerFunc { +func liveDebugging(host service.Host, callbackManager livedebugging.CallbackManager) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) componentID := livedebugging.ComponentID(vars["id"]) @@ -129,7 +136,7 @@ func (a *AlloyAPI) liveDebugging() http.HandlerFunc { id := livedebugging.CallbackID(uuid.New().String()) - err := a.CallbackManager.AddCallback(id, componentID, func(data string) { + err := callbackManager.AddCallback(id, componentID, func(data string) { select { case <-ctx.Done(): return @@ -152,7 +159,7 @@ func (a *AlloyAPI) liveDebugging() http.HandlerFunc { defer func() { close(dataCh) - a.CallbackManager.DeleteCallback(id, componentID) + callbackManager.DeleteCallback(id, componentID) }() for { diff --git a/internal/web/ui/src/Router.tsx b/internal/web/ui/src/Router.tsx index eb4c970d67..602bfc8815 100644 --- a/internal/web/ui/src/Router.tsx +++ b/internal/web/ui/src/Router.tsx @@ -6,6 +6,8 @@ import ComponentDetailPage from './pages/ComponentDetailPage'; import Graph from './pages/Graph'; import PageLiveDebugging from './pages/LiveDebugging'; import PageComponentList from './pages/PageComponentList'; +import PageRemoteComponentList from './pages/PageRemoteComponentList'; +import RemoteComponentDetailPage from './pages/RemoteComponentDetailPage'; interface Props { basePath: string; @@ -18,7 +20,11 @@ const Router = ({ basePath }: Props) => {
} /> + } /> + } /> + } /> + } /> } /> } /> diff --git a/internal/web/ui/src/features/component/ComponentList.tsx b/internal/web/ui/src/features/component/ComponentList.tsx index cc9662b3c4..b5224583e0 100644 --- a/internal/web/ui/src/features/component/ComponentList.tsx +++ b/internal/web/ui/src/features/component/ComponentList.tsx @@ -10,13 +10,15 @@ import styles from './ComponentList.module.css'; interface ComponentListProps { components: ComponentInfo[]; moduleID?: string; + useRemotecfg: boolean; handleSorting?: (sortField: string, sortOrder: SortOrder) => void; } const TABLEHEADERS = ['Health', 'ID']; -const ComponentList = ({ components, moduleID, handleSorting }: ComponentListProps) => { +const ComponentList = ({ components, moduleID, useRemotecfg, handleSorting }: ComponentListProps) => { const tableStyles = { width: '130px' }; + const urlPrefix = useRemotecfg ? '/remotecfg' : ''; const pathPrefix = moduleID ? moduleID + '/' : ''; /** @@ -30,7 +32,7 @@ const ComponentList = ({ components, moduleID, handleSorting }: ComponentListPro {id} - + View diff --git a/internal/web/ui/src/features/component/ComponentView.tsx b/internal/web/ui/src/features/component/ComponentView.tsx index 46c8d3c8ac..81b81c1d6f 100644 --- a/internal/web/ui/src/features/component/ComponentView.tsx +++ b/internal/web/ui/src/features/component/ComponentView.tsx @@ -1,5 +1,6 @@ import { FC, Fragment, ReactElement } from 'react'; import { Link } from 'react-router-dom'; +import { useLocation } from 'react-router-dom'; import { faBug, faCubes, faLink } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; @@ -26,6 +27,8 @@ export const ComponentView: FC = (props) => { const argsPartition = partitionBody(props.component.arguments, 'Arguments'); const exportsPartition = props.component.exports && partitionBody(props.component.exports, 'Exports'); const debugPartition = props.component.debugInfo && partitionBody(props.component.debugInfo, 'Debug info'); + const location = useLocation(); + const useRemotecfg = location.pathname.startsWith('/remotecfg'); function partitionTOC(partition: PartitionedBody): ReactElement { return ( @@ -100,11 +103,15 @@ export const ComponentView: FC = (props) => { - + {useRemotecfg ? ( + 'Live debugging is not yet available for remote components' + ) : ( + + )} {props.component.health.message && (
@@ -126,7 +133,7 @@ export const ComponentView: FC = (props) => {

Dependencies

- +
)} @@ -135,7 +142,7 @@ export const ComponentView: FC = (props) => {

Dependants

- +
)} @@ -146,6 +153,7 @@ export const ComponentView: FC = (props) => {
diff --git a/internal/web/ui/src/features/layout/Navbar.tsx b/internal/web/ui/src/features/layout/Navbar.tsx index dcc409921f..ace1834039 100644 --- a/internal/web/ui/src/features/layout/Navbar.tsx +++ b/internal/web/ui/src/features/layout/Navbar.tsx @@ -23,6 +23,11 @@ function Navbar() { Clustering +
  • + + Remote Configuration + +
  • Help
  • diff --git a/internal/web/ui/src/hooks/componentInfo.tsx b/internal/web/ui/src/hooks/componentInfo.tsx index 4dc2015a9a..4be91be0a2 100644 --- a/internal/web/ui/src/hooks/componentInfo.tsx +++ b/internal/web/ui/src/hooks/componentInfo.tsx @@ -9,14 +9,21 @@ import { ComponentInfo } from '../features/component/types'; * determining the proper list of components from the context of a module. */ export const useComponentInfo = ( - moduleID: string + moduleID: string, + isRemotecfg: boolean ): [ComponentInfo[], React.Dispatch>] => { const [components, setComponents] = useState([]); useEffect( function () { const worker = async () => { - const infoPath = moduleID === '' ? './api/v0/web/components' : `./api/v0/web/modules/${moduleID}/components`; + const infoPath = isRemotecfg + ? moduleID === '' + ? './api/v0/web/remotecfg/components' + : `./api/v0/web/remotecfg/modules/${moduleID}/components` + : moduleID === '' + ? './api/v0/web/components' + : `./api/v0/web/modules/${moduleID}/components`; // Request is relative to the tag inside of . const resp = await fetch(infoPath, { @@ -28,7 +35,7 @@ export const useComponentInfo = ( worker().catch(console.error); }, - [moduleID] + [moduleID, isRemotecfg] ); return [components, setComponents]; diff --git a/internal/web/ui/src/pages/ComponentDetailPage.tsx b/internal/web/ui/src/pages/ComponentDetailPage.tsx index 1aa325c250..0f664439d4 100644 --- a/internal/web/ui/src/pages/ComponentDetailPage.tsx +++ b/internal/web/ui/src/pages/ComponentDetailPage.tsx @@ -8,9 +8,8 @@ import { parseID } from '../utils/id'; const ComponentDetailPage: FC = () => { const { '*': id } = useParams(); - const { moduleID } = parseID(id || ''); - const [components] = useComponentInfo(moduleID); + const [components] = useComponentInfo(moduleID, false); const infoByID = componentInfoByID(components); const [component, setComponent] = useState(undefined); @@ -21,16 +20,19 @@ const ComponentDetailPage: FC = () => { return; } + const fetchURL = `./api/v0/web/components/${id}`; const worker = async () => { // Request is relative to the tag inside of . - const resp = await fetch(`./api/v0/web/components/${id}`, { + const resp = await fetch(fetchURL, { cache: 'no-cache', credentials: 'same-origin', }); const data: ComponentDetail = await resp.json(); for (const moduleID of data.createdModuleIDs || []) { - const moduleComponentsResp = await fetch(`./api/v0/web/modules/${moduleID}/components`, { + const modulesURL = `./api/v0/web/modules/${moduleID}/components`; + + const moduleComponentsResp = await fetch(modulesURL, { cache: 'no-cache', credentials: 'same-origin', }); diff --git a/internal/web/ui/src/pages/Graph.tsx b/internal/web/ui/src/pages/Graph.tsx index 617fa0bb64..78523f4cba 100644 --- a/internal/web/ui/src/pages/Graph.tsx +++ b/internal/web/ui/src/pages/Graph.tsx @@ -5,7 +5,7 @@ import Page from '../features/layout/Page'; import { useComponentInfo } from '../hooks/componentInfo'; function Graph() { - const [components] = useComponentInfo(''); + const [components] = useComponentInfo('', false); return ( diff --git a/internal/web/ui/src/pages/PageComponentList.tsx b/internal/web/ui/src/pages/PageComponentList.tsx index f5b2dc0351..9a27ae3f7c 100644 --- a/internal/web/ui/src/pages/PageComponentList.tsx +++ b/internal/web/ui/src/pages/PageComponentList.tsx @@ -17,7 +17,7 @@ function getSortValue(component: ComponentInfo, field: string): string | undefin } function PageComponentList() { - const [components, setComponents] = useComponentInfo(''); + const [components, setComponents] = useComponentInfo('', false); // TODO: make this sorting logic reusable const handleSorting = (sortField: string, sortOrder: SortOrder): void => { @@ -38,7 +38,7 @@ function PageComponentList() { return ( - + ); } diff --git a/internal/web/ui/src/pages/PageRemoteComponentList.tsx b/internal/web/ui/src/pages/PageRemoteComponentList.tsx new file mode 100644 index 0000000000..dd86ced561 --- /dev/null +++ b/internal/web/ui/src/pages/PageRemoteComponentList.tsx @@ -0,0 +1,46 @@ +import { faCubes } from '@fortawesome/free-solid-svg-icons'; + +import ComponentList from '../features/component/ComponentList'; +import { ComponentInfo, SortOrder } from '../features/component/types'; +import Page from '../features/layout/Page'; +import { useComponentInfo } from '../hooks/componentInfo'; + +const fieldMappings: { [key: string]: (comp: ComponentInfo) => string | undefined } = { + Health: (comp) => comp.health?.state?.toString(), + ID: (comp) => comp.localID, + // Add new fields if needed here. +}; + +function getSortValue(component: ComponentInfo, field: string): string | undefined { + const valueGetter = fieldMappings[field]; + return valueGetter ? valueGetter(component) : undefined; +} + +function PageRemoteComponentList() { + const [components, setComponents] = useComponentInfo('', true); + + // TODO: make this sorting logic reusable + const handleSorting = (sortField: string, sortOrder: SortOrder): void => { + if (!sortField || !sortOrder) return; + const sorted = [...components].sort((a, b) => { + const sortValueA = getSortValue(a, sortField); + const sortValueB = getSortValue(b, sortField); + if (!sortValueA) return 1; + if (!sortValueB) return -1; + return ( + sortValueA.localeCompare(sortValueB, 'en', { + numeric: true, + }) * (sortOrder === SortOrder.ASC ? 1 : -1) + ); + }); + setComponents(sorted); + }; + + return ( + + + + ); +} + +export default PageRemoteComponentList; diff --git a/internal/web/ui/src/pages/RemoteComponentDetailPage.tsx b/internal/web/ui/src/pages/RemoteComponentDetailPage.tsx new file mode 100644 index 0000000000..343e84ad19 --- /dev/null +++ b/internal/web/ui/src/pages/RemoteComponentDetailPage.tsx @@ -0,0 +1,56 @@ +import { FC, useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; + +import { ComponentView } from '../features/component/ComponentView'; +import { ComponentDetail, ComponentInfo, componentInfoByID } from '../features/component/types'; +import { useComponentInfo } from '../hooks/componentInfo'; +import { parseID } from '../utils/id'; + +const RemoteComponentDetailPage: FC = () => { + const { '*': id } = useParams(); + + const { moduleID } = parseID(id || ''); + const [components] = useComponentInfo(moduleID, true); + const infoByID = componentInfoByID(components); + + const [component, setComponent] = useState(undefined); + + useEffect( + function () { + if (id === undefined) { + return; + } + + const fetchURL = `./api/v0/web/remotecfg/components/${id}`; + const worker = async () => { + // Request is relative to the tag inside of . + const resp = await fetch(fetchURL, { + cache: 'no-cache', + credentials: 'same-origin', + }); + const data: ComponentDetail = await resp.json(); + + for (const moduleID of data.createdModuleIDs || []) { + const modulesURL = `./api/v0/web/remotecfg/modules/${moduleID}/components`; + + const moduleComponentsResp = await fetch(modulesURL, { + cache: 'no-cache', + credentials: 'same-origin', + }); + const moduleComponents = (await moduleComponentsResp.json()) as ComponentInfo[]; + + data.moduleInfo = (data.moduleInfo || []).concat(moduleComponents); + } + + setComponent(data); + }; + + worker().catch(console.error); + }, + [id] + ); + + return component ? :
    ; +}; + +export default RemoteComponentDetailPage;