Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion front/src/routes/devices/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import { connect } from 'unistore/preact';
import get from 'get-value';

import withIntlAsProp from '../../utils/withIntlAsProp';
import disambiguateIntegrationNames from '../../utils/integrationNames';
import DevicesPage from './DevicesPage';
import { getDeviceIntegration, disambiguateIntegrationNames } from './integrationLinks';
import { getDeviceIntegration } from './integrationLinks';

class Devices extends Component {
// The endpoint returns the whole list: load it once, then search, order
Expand Down
38 changes: 1 addition & 37 deletions front/src/routes/devices/integrationLinks.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const DEVICE_EDIT_LINKS = {
* - name: raw name to display when there is no translation
* - external: true for a community integration (own group in the filter)
* - discriminant: technical identity, displayed only when two integrations
* share the same name (see disambiguateIntegrationNames)
* share the same name (see utils/integrationNames)
* - url: integration page listing the devices
* - deviceUrl: most specific page for this device in its integration
*/
Expand Down Expand Up @@ -79,39 +79,3 @@ export function getDeviceIntegration(device) {
deviceUrl: buildDeviceLink ? buildDeviceLink(device.selector) : url
};
}

/**
* Two community integrations can display the same name (two repositories
* publishing a manifest with the same name, or two dev installs of the same
* integration): those are the only ones that carry their technical identity
* next to their name, so the common case stays readable. Built-in integrations
* have unique names, and are told apart from community ones by their own group
* in the filter and by the "community" tag in the list.
* @param {Array} integrations - Integrations of the listed devices, with duplicates.
* @returns {Map} Name to display, by integration slug.
*/
export function disambiguateIntegrationNames(integrations) {
// names are compared lowercased: two manifests differing only by case read
// as the same name in the list
const slugsByName = new Map();
integrations.forEach(integration => {
if (integration && integration.external) {
const key = integration.name.toLowerCase();
const slugs = slugsByName.get(key) || new Set();
slugs.add(integration.slug);
slugsByName.set(key, slugs);
}
});
const nameBySlug = new Map();
integrations.forEach(integration => {
if (!integration || nameBySlug.has(integration.slug)) {
return;
}
const isDuplicated = integration.external && slugsByName.get(integration.name.toLowerCase()).size > 1;
nameBySlug.set(
integration.slug,
isDuplicated ? `${integration.name} (${integration.discriminant})` : integration.name
);
});
return nameBySlug;
}
28 changes: 14 additions & 14 deletions front/src/routes/settings/settings-service/ServiceItem.jsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { Component } from 'preact';
import { Text, Localizer } from 'preact-i18n';
import { Link } from 'preact-router';
import get from 'get-value';

import { RequestStatus } from '../../../utils/consts';
import { SERVICE_STATUS } from '../../../../../server/utils/constants';
import { integrations } from '../../../config/integrations';
import style from './style.css';

const STARTED_STATUS = [SERVICE_STATUS.RUNNING];
const HIDDEN_ACTION_STATUS = [SERVICE_STATUS.UNKNOWN, SERVICE_STATUS.DISABLED];
Expand All @@ -31,24 +30,25 @@ class ServiceItem extends Component {
}
};

render({ service }, { changeStatus }) {
render({ service, integration }, { changeStatus }) {
const started = STARTED_STATUS.includes(service.status);
const displayAction = !HIDDEN_ACTION_STATUS.includes(service.status);
const integrationPage = integrations.find(
integration => get(integration, 'link', { default: integration.key }).toLowerCase() === service.selector
);
const integrationLink =
integrationPage &&
`/dashboard/integration/${integrationPage.type}/${(integrationPage.link || integrationPage.key).toLowerCase()}`;
const integrationKey = integrationPage && integrationPage.key;

const changingStatus = changeStatus === RequestStatus.Getting;

return (
<tr>
<td>
<div style="max-width: 400px; overflow: hidden">
<Text id={`integration.${integrationKey}.title`}>{service.name}</Text>
<div class={style.serviceName}>
{integration.i18nKey ? <Text id={integration.i18nKey}>{integration.name}</Text> : integration.name}
{integration.external && (
// same tag as in the integration catalog: the list mixes both
// families, and a community integration can be named like a
// built-in one
<span class="badge badge-secondary">
<Text id="integration.tags.external" />
</span>
)}
</div>
<div class="small text-muted">
<Text id="servicesSettings.selector" fields={{ key: service.selector }} />
Expand Down Expand Up @@ -78,10 +78,10 @@ class ServiceItem extends Component {
)}
</td>
<td>
{integrationPage && (
{integration.url && (
<Localizer>
<Link
href={integrationLink}
href={integration.url}
class="btn btn-outline-secondary border-0"
title={<Text id="servicesSettings.integrationLinkTitle" />}
>
Expand Down
9 changes: 7 additions & 2 deletions front/src/routes/settings/settings-service/ServicesPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,13 @@ const ServicesPage = ({ services, integrations, actionOnService }) => (
</thead>
<tbody>
{services &&
services.map(service => (
<ServiceItem service={service} integrations={integrations} actionOnService={actionOnService} />
services.map(({ service, integration }) => (
<ServiceItem
service={service}
integration={integration}
integrations={integrations}
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
actionOnService={actionOnService}
/>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
))}
</tbody>
</table>
Expand Down
33 changes: 29 additions & 4 deletions front/src/routes/settings/settings-service/index.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import { Component } from 'preact';
import { connect } from 'unistore/preact';
import ServicesPage from './ServicesPage';
import get from 'get-value';
import update from 'immutability-helper';

import withIntlAsProp from '../../../utils/withIntlAsProp';
import disambiguateIntegrationNames from '../../../utils/integrationNames';
import ServicesPage from './ServicesPage';
import getServiceIntegration from './serviceIntegration';

class SettingsServices extends Component {
getServices = async (podId = null) => {
try {
const query = {
pod_id: podId
};
const services = await this.props.httpClient.get(`/api/v1/service`, query);
services.sort((s1, s2) => s1.name.localeCompare(s2.name));
this.setState({
services
});
Expand Down Expand Up @@ -44,10 +48,31 @@ class SettingsServices extends Component {
}

render(props, { services }) {
const integrations = (services || []).map(service => getServiceIntegration(service));
// names are resolved on the whole list: whether an integration needs its
// technical identity displayed depends on the other integrations present
const nameBySlug = disambiguateIntegrationNames(integrations);
// sorted on the label the row actually displays: a built-in integration is
// listed under its translated title, which its service name does not always
// match (in French, the "rtsp-camera" service reads "Caméras"), and a
// community integration under its manifest name, not its docker image tag
const getDisplayedName = integration =>
(integration.i18nKey && get(props.intl.dictionary, integration.i18nKey)) || nameBySlug.get(integration.slug);
const servicesWithIntegration = (services || [])
.map((service, index) => ({
service,
integration: { ...integrations[index], name: nameBySlug.get(integrations[index].slug) }
}))
.sort((a, b) =>
getDisplayedName(a.integration).localeCompare(getDisplayedName(b.integration), undefined, {
sensitivity: 'base'
})
);

return (
<ServicesPage
{...props}
services={services}
services={services && servicesWithIntegration}
startService={this.startService}
stopService={this.stopService}
actionOnService={this.actionOnService}
Expand All @@ -56,4 +81,4 @@ class SettingsServices extends Component {
}
}

export default connect('httpClient', {})(SettingsServices);
export default withIntlAsProp(connect('httpClient', {})(SettingsServices));
47 changes: 47 additions & 0 deletions front/src/routes/settings/settings-service/serviceIntegration.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import get from 'get-value';

import { integrations } from '../../../config/integrations';
import { SERVICE_TYPES } from '../../../../../server/utils/constants';

/**
* Returns how a service should be presented in the services list:
* - slug: identity of the service in the list (its selector, unique)
* - i18nKey: translation key of the integration name (built-in integrations)
* - name: raw name to display when there is no translation
* - external: true for a community integration
* - discriminant: technical identity, displayed only when two integrations
* share the same name (see utils/integrationNames)
* - url: integration page of the service, when it has one
* @param {object} service - Service returned by the API.
* @returns {object} Display identity of the service.
*/
function getServiceIntegration(service) {
if (service.type === SERVICE_TYPES.EXTERNAL) {
// The service name is the technical selector (ext-<owner>-<repo>): display
// the manifest name, the same title as the integration card in the catalog
return {
slug: service.selector,
name: get(service, 'manifest.name') || service.name,
external: true,
// the store slug (owner/repo) reads better than the selector built from
// it; dev installs have none, their selector is the only identity
discriminant: service.store_slug || service.selector,
url: null
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
};
}
const integrationPage = integrations.find(
integration => get(integration, 'link', { default: integration.key }).toLowerCase() === service.selector
);
if (!integrationPage) {
// Service without a front-end page (usb, example...): no link
return { slug: service.selector, name: service.name, url: null };
}
return {
slug: service.selector,
i18nKey: `integration.${integrationPage.key}.title`,
name: service.name,
url: `/dashboard/integration/${integrationPage.type}/${(integrationPage.link || integrationPage.key).toLowerCase()}`
};
}

export default getServiceIntegration;
7 changes: 7 additions & 0 deletions front/src/routes/settings/settings-service/style.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.serviceName {
display: flex;
align-items: center;
gap: 0.5rem;
max-width: 400px;
overflow: hidden;
Comment thread
cursor[bot] marked this conversation as resolved.
}
44 changes: 44 additions & 0 deletions front/src/utils/integrationNames.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* Two community integrations can display the same name (two repositories
* publishing a manifest with the same name, or two dev installs of the same
* integration): those are the only ones that carry their technical identity
* next to their name, so the common case stays readable. Built-in integrations
* have unique names, and are told apart from community ones by the "community"
* tag displayed next to them.
*
* Integrations are described by:
* - slug: identity of the integration, the key of the returned map
* - name: name to display, before disambiguation
* - external: true for a community integration
* - discriminant: technical identity, appended to the name when needed
*
* @param {Array} integrations - Listed integrations, duplicates allowed.
* @returns {Map} Name to display, by integration slug.
*/
function disambiguateIntegrationNames(integrations) {
// names are compared lowercased: two manifests differing only by case read
// as the same name in the list
const slugsByName = new Map();
integrations.forEach(integration => {
if (integration && integration.external) {
const key = integration.name.toLowerCase();
const slugs = slugsByName.get(key) || new Set();
slugs.add(integration.slug);
slugsByName.set(key, slugs);
}
});
const nameBySlug = new Map();
integrations.forEach(integration => {
if (!integration || nameBySlug.has(integration.slug)) {
return;
}
const isDuplicated = integration.external && slugsByName.get(integration.name.toLowerCase()).size > 1;
nameBySlug.set(
integration.slug,
isDuplicated ? `${integration.name} (${integration.discriminant})` : integration.name
);
});
return nameBySlug;
}

export default disambiguateIntegrationNames;
Loading