Skip to content

Commit

Permalink
Display remote configuration components in Alloy's local UI (#1472)
Browse files Browse the repository at this point in the history
Signed-off-by: Paschalis Tsilias <[email protected]>
  • Loading branch information
tpaschalis authored Aug 26, 2024
1 parent 5206290 commit 2c56755
Show file tree
Hide file tree
Showing 12 changed files with 180 additions and 37 deletions.
8 changes: 6 additions & 2 deletions internal/service/ui/ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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,
}
}
Expand Down Expand Up @@ -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)

Expand Down
39 changes: 23 additions & 16 deletions internal/web/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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 {
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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"])
Expand All @@ -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
Expand All @@ -152,7 +159,7 @@ func (a *AlloyAPI) liveDebugging() http.HandlerFunc {

defer func() {
close(dataCh)
a.CallbackManager.DeleteCallback(id, componentID)
callbackManager.DeleteCallback(id, componentID)
}()

for {
Expand Down
6 changes: 6 additions & 0 deletions internal/web/ui/src/Router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -18,7 +20,11 @@ const Router = ({ basePath }: Props) => {
<main>
<Routes>
<Route path="/" element={<PageComponentList />} />
<Route path="/remotecfg" element={<PageRemoteComponentList />} />

<Route path="/component/*" element={<ComponentDetailPage />} />
<Route path="/remotecfg/component/*" element={<RemoteComponentDetailPage />} />

<Route path="/graph" element={<Graph />} />
<Route path="/clustering" element={<PageClusteringPeers />} />
<Route path="/debug/*" element={<PageLiveDebugging />} />
Expand Down
6 changes: 4 additions & 2 deletions internal/web/ui/src/features/component/ComponentList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 + '/' : '';

/**
Expand All @@ -30,7 +32,7 @@ const ComponentList = ({ components, moduleID, handleSorting }: ComponentListPro
</td>
<td className={styles.idColumn}>
<span className={styles.idName}>{id}</span>
<NavLink to={'/component/' + pathPrefix + id} className={styles.viewButton}>
<NavLink to={urlPrefix + '/component/' + pathPrefix + id} className={styles.viewButton}>
View
</NavLink>
</td>
Expand Down
22 changes: 15 additions & 7 deletions internal/web/ui/src/features/component/ComponentView.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -26,6 +27,8 @@ export const ComponentView: FC<ComponentViewProps> = (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 (
Expand Down Expand Up @@ -100,11 +103,15 @@ export const ComponentView: FC<ComponentViewProps> = (props) => {
</a>
</div>

<div className={styles.debugLink}>
<a href={`debug/${pathJoin([props.component.moduleID, props.component.localID])}`}>
<FontAwesomeIcon icon={faBug} /> Live debugging
</a>
</div>
{useRemotecfg ? (
'Live debugging is not yet available for remote components'
) : (
<div className={styles.debugLink}>
<a href={`debug/${pathJoin([props.component.moduleID, props.component.localID])}`}>
<FontAwesomeIcon icon={faBug} /> Live debugging
</a>
</div>
)}

{props.component.health.message && (
<blockquote>
Expand All @@ -126,7 +133,7 @@ export const ComponentView: FC<ComponentViewProps> = (props) => {
<section id="dependencies">
<h2>Dependencies</h2>
<div className={styles.sectionContent}>
<ComponentList components={referencesTo} moduleID={props.component.moduleID} />
<ComponentList components={referencesTo} useRemotecfg={useRemotecfg} moduleID={props.component.moduleID} />
</div>
</section>
)}
Expand All @@ -135,7 +142,7 @@ export const ComponentView: FC<ComponentViewProps> = (props) => {
<section id="dependants">
<h2>Dependants</h2>
<div className={styles.sectionContent}>
<ComponentList components={referencedBy} moduleID={props.component.moduleID} />
<ComponentList components={referencedBy} useRemotecfg={useRemotecfg} moduleID={props.component.moduleID} />
</div>
</section>
)}
Expand All @@ -146,6 +153,7 @@ export const ComponentView: FC<ComponentViewProps> = (props) => {
<div className={styles.sectionContent}>
<ComponentList
components={props.component.moduleInfo}
useRemotecfg={useRemotecfg}
moduleID={pathJoin([props.component.moduleID, props.component.localID])}
/>
</div>
Expand Down
5 changes: 5 additions & 0 deletions internal/web/ui/src/features/layout/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ function Navbar() {
Clustering
</NavLink>
</li>
<li>
<NavLink to="/remotecfg" className="nav-link">
Remote Configuration
</NavLink>
</li>
<li>
<a href="https://grafana.com/docs/alloy/latest">Help</a>
</li>
Expand Down
13 changes: 10 additions & 3 deletions internal/web/ui/src/hooks/componentInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<React.SetStateAction<ComponentInfo[]>>] => {
const [components, setComponents] = useState<ComponentInfo[]>([]);

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 <base> tag inside of <head>.
const resp = await fetch(infoPath, {
Expand All @@ -28,7 +35,7 @@ export const useComponentInfo = (

worker().catch(console.error);
},
[moduleID]
[moduleID, isRemotecfg]
);

return [components, setComponents];
Expand Down
10 changes: 6 additions & 4 deletions internal/web/ui/src/pages/ComponentDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ComponentDetail | undefined>(undefined);
Expand All @@ -21,16 +20,19 @@ const ComponentDetailPage: FC = () => {
return;
}

const fetchURL = `./api/v0/web/components/${id}`;
const worker = async () => {
// Request is relative to the <base> tag inside of <head>.
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',
});
Expand Down
2 changes: 1 addition & 1 deletion internal/web/ui/src/pages/Graph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Page name="Graph" desc="Relationships between defined components" icon={faDiagramProject}>
Expand Down
4 changes: 2 additions & 2 deletions internal/web/ui/src/pages/PageComponentList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand All @@ -38,7 +38,7 @@ function PageComponentList() {

return (
<Page name="Components" desc="List of defined components" icon={faCubes}>
<ComponentList components={components} handleSorting={handleSorting} />
<ComponentList components={components} useRemotecfg={false} handleSorting={handleSorting} />
</Page>
);
}
Expand Down
46 changes: 46 additions & 0 deletions internal/web/ui/src/pages/PageRemoteComponentList.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Page name="Remote Configuration" desc="List of remote configuration pipelines" icon={faCubes}>
<ComponentList components={components} useRemotecfg={true} handleSorting={handleSorting} />
</Page>
);
}

export default PageRemoteComponentList;
Loading

0 comments on commit 2c56755

Please sign in to comment.