Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
06902cc
feat: new App instances page & mixed app status
MartinSchoeler Jun 30, 2025
0382c22
Test: add tests
MartinSchoeler Jul 4, 2025
2b33af6
test: Adjust snapshot
MartinSchoeler Jul 4, 2025
4b14a04
Prevent /api/apps/installed from throwing when no cluster information
d-gubert Jul 7, 2025
04db8b6
Create mean-apricots-confess.md
MartinSchoeler Jul 10, 2025
7e65dc4
chore: use GenericMenu
MartinSchoeler Jul 10, 2025
bed84e8
fix: instance pages with no cluster
MartinSchoeler Jul 10, 2025
3a3c449
Merge remote-tracking branch 'origin/develop' into feat/app-instances…
MartinSchoeler Jul 10, 2025
de31b3a
chore: remove unused rule
MartinSchoeler Jul 10, 2025
af698f6
Update Jest snapshot
tassoevan Jul 10, 2025
1f0732b
chore: reviews
MartinSchoeler Jul 11, 2025
ccf9d6a
Merge branch 'develop' into feat/app-instances-page
MartinSchoeler Jul 11, 2025
9695cca
nitpick: remove inline type
MartinSchoeler Jul 11, 2025
e86aed9
Merge remote-tracking branch 'origin/develop' into feat/app-instances…
MartinSchoeler Jul 15, 2025
1de5a33
fix: translation key typo
MartinSchoeler Jul 15, 2025
8644e16
Merge branch 'develop' into feat/app-instances-page
d-gubert Jul 15, 2025
4160a58
Merge branch 'develop' into feat/app-instances-page
d-gubert Jul 16, 2025
4526ddc
Return the local instance when querying the app status across a cluster
d-gubert Jul 16, 2025
a11c4d7
Sort cluster status by instance in alphabetical order
d-gubert Jul 16, 2025
0f58503
Merge branch 'develop' into feat/app-instances-page
d-gubert Jul 16, 2025
70aea8a
Fix unit tests
d-gubert Jul 16, 2025
a044772
chore: prettier :)
MartinSchoeler Jul 16, 2025
62e5c06
Merge branch 'develop' into feat/app-instances-page
MartinSchoeler Jul 17, 2025
de8840f
chore: update storybook
MartinSchoeler Jul 17, 2025
9b9bd74
Merge remote-tracking branch 'origin/develop' into feat/app-instances…
MartinSchoeler Jul 17, 2025
b26d269
Rearrange story declarations
tassoevan Jul 17, 2025
cc28fb9
Merge branch 'develop' into feat/app-instances-page
kodiakhq[bot] Jul 17, 2025
1c8d1ac
Merge branch 'develop' into feat/app-instances-page
kodiakhq[bot] Jul 17, 2025
36e9e70
Merge branch 'develop' into feat/app-instances-page
kodiakhq[bot] Jul 17, 2025
3fd05ad
Merge branch 'develop' into feat/app-instances-page
kodiakhq[bot] Jul 17, 2025
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
7 changes: 7 additions & 0 deletions .changeset/mean-apricots-confess.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@rocket.chat/meteor": minor
"@rocket.chat/i18n": minor
---

Adds a new tab on App Details page to see all instances and it's statuses.
Adds a new variant to the status tag, mixed status.
2 changes: 1 addition & 1 deletion apps/meteor/client/apps/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ class AppClientOrchestrator {
}

public async getInstalledApps(): Promise<App[]> {
const result = await sdk.rest.get<'/apps/installed'>('/apps/installed');
const result = await sdk.rest.get<'/apps/installed'>('/apps/installed', { includeClusterStatus: 'true' });

if ('apps' in result) {
// TODO: chapter day: multiple results are returned, but we only need one
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export function storeQueryFunction(
...app,
...(installedApp && {
private: installedApp.private,
clusterStatus: installedApp.clusterStatus,
installed: true,
status: installedApp.status,
version: installedApp.version,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import AppDetailsPageTabs from './AppDetailsPageTabs';
import { handleAPIError } from '../helpers/handleAPIError';
import { useAppInfo } from '../hooks/useAppInfo';
import AppDetails from './tabs/AppDetails';
import AppInstances from './tabs/AppInstances';
import AppLogs from './tabs/AppLogs';
import AppReleases from './tabs/AppReleases';
import AppRequests from './tabs/AppRequests/AppRequests';
Expand Down Expand Up @@ -99,6 +100,7 @@ const AppDetailsPage = ({ id }: AppDetailsPageProps): ReactElement => {
isSecurityVisible={isSecurityVisible}
settings={settings}
tab={tab}
hasCluster={!!appData.clusterStatus}
/>
{Boolean(!tab || tab === 'details') && <AppDetails app={appData} />}
{tab === 'requests' && <AppRequests id={id} isAdminUser={isAdminUser} />}
Expand All @@ -117,6 +119,7 @@ const AppDetailsPage = ({ id }: AppDetailsPageProps): ReactElement => {
</FormProvider>
)}
{tab === 'logs' && <AppLogs id={id} />}
{tab === 'instances' && <AppInstances id={id} />}
</>
)}
</Box>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,23 @@ type AppDetailsPageTabsProps = {
isSecurityVisible: boolean;
settings: ISettings | undefined;
tab: string | undefined;
hasCluster: boolean;
};

const AppDetailsPageTabs = ({ context, installed, isSecurityVisible, settings, tab }: AppDetailsPageTabsProps): ReactElement => {
const AppDetailsPageTabs = ({
context,
installed = false,
isSecurityVisible,
settings,
tab,
hasCluster = false,
}: AppDetailsPageTabsProps): ReactElement => {
const { t } = useTranslation();
const isAdminUser = usePermission('manage-apps');

const router = useRouter();

const handleTabClick = (tab: 'details' | 'security' | 'releases' | 'settings' | 'logs' | 'requests') => {
const handleTabClick = (tab: 'details' | 'security' | 'releases' | 'settings' | 'logs' | 'requests' | 'instances') => {
router.navigate(
{
name: 'marketplace',
Expand Down Expand Up @@ -49,16 +57,21 @@ const AppDetailsPageTabs = ({ context, installed, isSecurityVisible, settings, t
{t('Releases')}
</Tabs.Item>
)}
{Boolean(installed && settings && Object.values(settings).length) && isAdminUser && (
{installed && Boolean(settings && Object.values(settings).length) && isAdminUser && (
<Tabs.Item onClick={() => handleTabClick('settings')} selected={tab === 'settings'}>
{t('Settings')}
</Tabs.Item>
)}
{Boolean(installed) && isAdminUser && isAdminUser && (
{installed && isAdminUser && (
<Tabs.Item onClick={() => handleTabClick('logs')} selected={tab === 'logs'}>
{t('Logs')}
</Tabs.Item>
)}
{hasCluster && installed && isAdminUser && (
<Tabs.Item onClick={() => handleTabClick('instances')} selected={tab === 'instances'}>
{t('Instances')}
</Tabs.Item>
)}
</Tabs>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { composeStories } from '@storybook/react';
import { render, screen } from '@testing-library/react';
import { axe } from 'jest-axe';

import * as stories from './AppInstances.stories';

const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);

test.each(testCases)(`renders AppInstances without crashing`, async (_storyname, Story) => {
const view = render(<Story />, { wrapper: mockAppRoot().build() });
expect(await screen.findByRole('table')).toBeInTheDocument();

expect(view.baseElement).toMatchSnapshot();
});

test.each(testCases)('AppInstances should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />, { wrapper: mockAppRoot().build() });

// Today we do not have exactly a pattern to handle menu cells that don't have a header
const results = await axe(container, { rules: { 'empty-table-header': { enabled: false } } });
expect(await screen.findByRole('table')).toBeInTheDocument();

expect(results).toHaveNoViolations();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';

import AppInstances from './AppInstances';

const statusPossibleStates = [
'unknown',
'constructed',
'initialized',
'auto_enabled',
'manually_enabled',
'compiler_error_disabled',
'invalid_license_disabled',
'invalid_installation_disabled',
'error_disabled',
'manually_disabled',
'invalid_settings_disabled',
'disabled',
];

const statusTranslations = {
App_status_auto_enabled: 'Enabled',
App_status_constructed: 'Constructed',
App_status_disabled: 'Disabled',
App_status_error_disabled: 'Disabled: Uncaught Error',
App_status_initialized: 'Initialized',
App_status_invalid_license_disabled: 'Disabled: Invalid License',
App_status_invalid_settings_disabled: 'Disabled: Configuration Needed',
App_status_manually_disabled: 'Disabled: Manually',
App_status_manually_enabled: 'Enabled',
App_status_unknown: 'Unknown',
App_status_invalid_installation_disabled: 'Disabled: Invalid Installation',
Workspace_instance: 'Workspace instance',
};

export default {
title: 'Components/AppInstances',
component: AppInstances,
decorators: [
mockAppRoot()
.withEndpoint('GET', '/apps/:id/status', () => ({
status: 'disabled',
// Using any since the actual values from the AppStatus Enum cannot be used because it was exported as a type
clusterStatus: statusPossibleStates.map((status, i) => ({ instanceId: `instance-id-${i}`, status })) as any,
}))
.withTranslations('en', 'core', statusTranslations)
.buildStoryDecorator(),
],
};

const Template = () => <AppInstances id='app-id' />;

export const Default = Template.bind({});
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import type { AppStatus } from '@rocket.chat/apps';
import { AppStatusUtils } from '@rocket.chat/apps-engine/definition/AppStatus';
import { Box, Palette, Tag } from '@rocket.chat/fuselage';
import { GenericMenu } from '@rocket.chat/ui-client';
import { useRouter } from '@rocket.chat/ui-contexts';
import type { ReactElement } from 'react';
import { useTranslation } from 'react-i18next';

import { CustomScrollbars } from '../../../../../components/CustomScrollbars';
import GenericNoResults from '../../../../../components/GenericNoResults';
import {
GenericTable,
GenericTableBody,
GenericTableCell,
GenericTableHeader,
GenericTableHeaderCell,
GenericTableRow,
} from '../../../../../components/GenericTable';
import AccordionLoading from '../../../components/AccordionLoading';
import { useAppInstances } from '../../../hooks/useAppInstances';

type AppInstanceProps = {
id: string;
};

const AppInstances = ({ id }: AppInstanceProps): ReactElement => {
const { t } = useTranslation();
const { data, isSuccess, isError, isLoading } = useAppInstances({ appId: id });

const getStatusColor = (status: AppStatus) => {
if (AppStatusUtils.isDisabled(status) || AppStatusUtils.isError(status)) {
return Palette.text['font-danger'].toString();
}

return Palette.text['font-default'].toString();
};

const router = useRouter();

const handleSelectLogs = () => {
router.navigate(
{
name: 'marketplace',
params: { ...router.getRouteParameters(), tab: 'logs' },
},
{ replace: true },
);
};

return (
<Box h='full' w='full' marginInline='auto' color='default' pbs={24}>
{isLoading && <AccordionLoading />}
{isError && (
<Box maxWidth='x600' alignSelf='center'>
{t('App_not_found')}
</Box>
)}
{isSuccess && data.clusterStatus && data.clusterStatus.length > 0 && (
<CustomScrollbars>
<GenericTable w='full'>
<GenericTableHeader>
<GenericTableHeaderCell key='instanceId'>{t('Workspace_instance')}</GenericTableHeaderCell>
<GenericTableHeaderCell key='status'>{t('Status')}</GenericTableHeaderCell>
<GenericTableHeaderCell key='actions' width={64} />
</GenericTableHeader>
<GenericTableBody>
{data?.clusterStatus?.map((instance) => (
<GenericTableRow key={instance.instanceId}>
<GenericTableCell>{instance.instanceId}</GenericTableCell>
<GenericTableCell>
<Box justifyContent='flex-start' display='flex'>
<Tag medium color={getStatusColor(instance.status)}>
{t(`App_status_${instance.status}`)}
</Tag>
</Box>
</GenericTableCell>
<GenericTableCell>
<GenericMenu
title='Actions'
items={[
{
content: t('View_logs'),
onClick: handleSelectLogs,
id: 'view-logs',
icon: 'desktop-text',
},
]}
/>
</GenericTableCell>
</GenericTableRow>
))}
</GenericTableBody>
</GenericTable>
</CustomScrollbars>
)}
{isSuccess && (!data.clusterStatus || data.clusterStatus.length === 0) && (
<CustomScrollbars>
<GenericNoResults />
</CustomScrollbars>
)}
</Box>
);
};

export default AppInstances;
Loading
Loading