LicenseInfo.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. import { css } from '@emotion/css';
  2. import React, { FormEvent, useState } from 'react';
  3. import { dateTimeFormat, GrafanaTheme2 } from '@grafana/data';
  4. import { locationService } from '@grafana/runtime';
  5. import { Alert, Button, LinkButton, useStyles2, Icon } from '@grafana/ui';
  6. import { contextSrv } from 'app/core/services/context_srv';
  7. import { UpgradeInfo } from 'app/features/admin/UpgradePage';
  8. import { Loader } from 'app/features/plugins/admin/components/Loader';
  9. import { AccessControlAction } from '../types';
  10. import { CustomerSupportButton } from './CustomerSupportButton';
  11. import { CardAlert, CardContent, CardState, LicenseCard } from './LicenseCard';
  12. import { LicenseTokenUpload } from './LicenseTokenUpload';
  13. import { EXPIRED, VALID, WARNING_RATE, LIMIT_BY_USERS, AWS_MARKEPLACE_ISSUER } from './constants';
  14. import { postLicenseToken, renewLicenseToken } from './state/api';
  15. import { ActiveUserStats, LicenseToken } from './types';
  16. import { getRate, getTokenStatus, getTrialStatus, getUserStatMessage, getUtilStatus } from './utils';
  17. export interface Props {
  18. token: LicenseToken | null;
  19. stats: ActiveUserStats | null;
  20. tokenRenewed?: boolean;
  21. tokenUpdated?: boolean;
  22. isLoading?: boolean;
  23. licensedUrl?: string;
  24. }
  25. export const LicenseInfo = ({ token, stats, tokenRenewed, tokenUpdated, isLoading, licensedUrl }: Props) => {
  26. const [isUploading, setIsUploading] = useState(false);
  27. const [isRenewing, setIsRenewing] = useState(false);
  28. const tokenState = getTokenStatus(token).state;
  29. const utilState = getUtilStatus(token, stats).state;
  30. const isLicensingEditor = contextSrv.hasAccess(AccessControlAction.LicensingWrite, contextSrv.isGrafanaAdmin);
  31. const styles = useStyles2(getStyles);
  32. const onFileUpload = (event: FormEvent<HTMLInputElement>) => {
  33. const file = event.currentTarget?.files?.[0];
  34. if (file) {
  35. locationService.partial({ tokenUpdated: null, tokenRenewed: null });
  36. const reader = new FileReader();
  37. const readerOnLoad = () => {
  38. return async (e: any) => {
  39. setIsUploading(true);
  40. try {
  41. await postLicenseToken(e.target.result);
  42. locationService.partial({ tokenUpdated: true });
  43. setTimeout(() => {
  44. // reload from server to pick up the new token
  45. location.reload();
  46. }, 1000);
  47. } catch (error) {
  48. setIsUploading(false);
  49. throw error;
  50. }
  51. };
  52. };
  53. reader.onload = readerOnLoad();
  54. reader.readAsText(file);
  55. }
  56. };
  57. const onRenewClick = async () => {
  58. locationService.partial({ tokenUpdated: null, tokenRenewed: null });
  59. setIsRenewing(true);
  60. try {
  61. await renewLicenseToken();
  62. locationService.partial({ tokenRenewed: true });
  63. setTimeout(() => {
  64. // reload from server to pick up the new token
  65. location.reload();
  66. }, 1000);
  67. } catch (error) {
  68. setIsRenewing(false);
  69. throw error;
  70. }
  71. };
  72. if (!contextSrv.hasAccess(AccessControlAction.LicensingRead, contextSrv.isGrafanaAdmin)) {
  73. return null;
  74. }
  75. if (isLoading) {
  76. return <Loader text={'Loading licensing info...'} />;
  77. }
  78. let editionNotice = 'You are running Grafana Enterprise without a license. The Enterprise features are disabled.';
  79. if (token && ![VALID, EXPIRED].includes(token.status)) {
  80. editionNotice = 'There is a problem with your Enterprise license token. The Enterprise features are disabled.';
  81. }
  82. return !token || ![VALID, EXPIRED].includes(token.status) ? (
  83. <>
  84. <UpgradeInfo editionNotice={editionNotice} />
  85. <div className={styles.uploadWrapper}>
  86. <LicenseTokenUpload
  87. title="Have a license?"
  88. onFileUpload={onFileUpload}
  89. isUploading={isUploading}
  90. isDisabled={!isLicensingEditor}
  91. licensedUrl={licensedUrl}
  92. />
  93. </div>
  94. </>
  95. ) : (
  96. <div>
  97. <h2 className={styles.title}>License details</h2>
  98. <PageAlert {...getUtilStatus(token, stats)} orgSlug={token.slug} licenseId={token.lid} />
  99. <PageAlert {...getTokenStatus(token)} orgSlug={token.slug} licenseId={token.lid} />
  100. <PageAlert {...getTrialStatus(token)} orgSlug={token.slug} licenseId={token.lid} />
  101. {tokenUpdated && (
  102. <Alert
  103. title="License token uploaded. Restart Grafana for the changes to take effect."
  104. severity="success"
  105. onRemove={() => locationService.partial({ tokenUpdated: null })}
  106. />
  107. )}
  108. {tokenRenewed && (
  109. <Alert
  110. title="License token renewed."
  111. severity="success"
  112. onRemove={() => locationService.partial({ tokenRenewed: null })}
  113. />
  114. )}
  115. <div className={styles.row}>
  116. <LicenseCard
  117. title={'License'}
  118. className={styles.licenseCard}
  119. footer={
  120. <LinkButton
  121. variant="secondary"
  122. href={token.details_url || `${token.iss}/licenses/${token.lid}`}
  123. aria-label="View details about your license in Grafana Cloud"
  124. target="_blank"
  125. rel="noopener noreferrer"
  126. >
  127. License details
  128. </LinkButton>
  129. }
  130. >
  131. <CardContent
  132. content={[
  133. {
  134. name: token.prod?.length <= 1 ? 'Product' : 'Products',
  135. value:
  136. token.prod?.length <= 1 ? (
  137. token.prod[0] || 'None'
  138. ) : (
  139. <ul>
  140. {token.prod?.map((product) => (
  141. <li key={product}>{product}</li>
  142. ))}
  143. </ul>
  144. ),
  145. },
  146. token.iss === AWS_MARKEPLACE_ISSUER && token.account
  147. ? { name: 'AWS Account', value: token.account }
  148. : { name: 'Company', value: token.company },
  149. { name: 'License ID', value: token.lid },
  150. token.iss === AWS_MARKEPLACE_ISSUER
  151. ? null
  152. : {
  153. name: 'URL',
  154. value: token.sub,
  155. tooltip:
  156. 'License URL is the root URL of your Grafana instance. The license will not work on an instance of Grafana with a different root URL.',
  157. },
  158. { name: 'Purchase date', value: dateTimeFormat(token.nbf * 1000) },
  159. token.iss === AWS_MARKEPLACE_ISSUER
  160. ? null
  161. : {
  162. name: 'License expires',
  163. value: dateTimeFormat(token.lexp * 1000),
  164. highlight: !!getTokenStatus(token)?.state,
  165. tooltip:
  166. 'The license expiration date is the date when the current license is no longer active. As the license expiration date approaches, Grafana Enterprise displays a banner.',
  167. },
  168. token.iss === AWS_MARKEPLACE_ISSUER
  169. ? null
  170. : {
  171. name: 'Usage billing',
  172. value: token.usage_billing ? 'On' : 'Off',
  173. tooltip:
  174. 'You can request Grafana Labs to turn on usage billing to allow an unlimited number of active users. When usage billing is enabled, Grafana does not enforce active user limits or display warning banners. Instead, you are charged for active users above the limit, according to your customer contract.',
  175. },
  176. ]}
  177. />
  178. </LicenseCard>
  179. <LicenseCard
  180. {...getTokenStatus(token)}
  181. title={'Token'}
  182. footer={
  183. <div className={styles.row}>
  184. {token.iss !== AWS_MARKEPLACE_ISSUER && (
  185. <LicenseTokenUpload
  186. onFileUpload={onFileUpload}
  187. isUploading={isUploading}
  188. isDisabled={!isLicensingEditor}
  189. />
  190. )}
  191. {isRenewing ? (
  192. <span> (Renewing...)</span>
  193. ) : (
  194. <Button variant="secondary" onClick={onRenewClick} disabled={!isLicensingEditor}>
  195. Renew token
  196. </Button>
  197. )}
  198. </div>
  199. }
  200. >
  201. <>
  202. {tokenState && (
  203. <CardAlert
  204. title={'Contact support to renew your token, or visit the Cloud portal to learn more.'}
  205. state={tokenState}
  206. orgSlug={token.slug}
  207. licenseId={token.lid}
  208. />
  209. )}
  210. <div className={styles.message}>
  211. <Icon name={'document-info'} />
  212. Read about{' '}
  213. <a
  214. href={'https://grafana.com/docs/grafana/latest/enterprise/license/license-expiration/'}
  215. target="_blank"
  216. rel="noreferrer noopener"
  217. >
  218. license expiration
  219. </a>{' '}
  220. and{' '}
  221. <a
  222. href={'https://grafana.com/docs/grafana/latest/enterprise/license/activate-license/'}
  223. target="_blank"
  224. rel="noreferrer noopener"
  225. >
  226. license activation
  227. </a>
  228. .
  229. </div>
  230. <CardContent
  231. content={[
  232. { name: 'Token ID', value: token.jti },
  233. { name: 'Token issued', value: dateTimeFormat(token.iat * 1000) },
  234. {
  235. name: 'Token expires',
  236. value: dateTimeFormat(token.exp * 1000),
  237. highlight: !!getTokenStatus(token)?.state,
  238. tooltip:
  239. 'Grafana automatically updates the token before it expires. If your token is not updating, contact support.',
  240. },
  241. ]}
  242. state={tokenState}
  243. />
  244. </>
  245. </LicenseCard>
  246. <LicenseCard
  247. {...getUtilStatus(token, stats)}
  248. title={'Utilization'}
  249. footer={
  250. <small className={styles.footerText}>
  251. Utilization of licenced users is determined based on signed-in users&apos; activity in the past 30 days.
  252. </small>
  253. }
  254. >
  255. <>
  256. <div className={styles.message}>
  257. <Icon name={'document-info'} />
  258. Read about{' '}
  259. <a
  260. href={
  261. 'https://grafana.com/docs/grafana/latest/enterprise/license/license-restrictions/#active-users-limit'
  262. }
  263. target="_blank"
  264. rel="noreferrer noopener"
  265. >
  266. active user limits
  267. </a>{' '}
  268. and{' '}
  269. <a
  270. href={
  271. 'https://grafana.com/docs/grafana/latest/enterprise/license/license-restrictions/#concurrent-sessions-limit'
  272. }
  273. target="_blank"
  274. rel="noreferrer noopener"
  275. >
  276. concurrent session limits
  277. </a>
  278. .
  279. </div>
  280. {token.limit_by === LIMIT_BY_USERS && (
  281. <CardContent
  282. content={[
  283. {
  284. name: 'Users',
  285. value: getUserStatMessage(token.included_users, stats?.active_users),
  286. highlight: getRate(token.included_users, stats?.active_users) >= WARNING_RATE,
  287. },
  288. ]}
  289. state={utilState}
  290. />
  291. )}
  292. </>
  293. </LicenseCard>
  294. </div>
  295. </div>
  296. );
  297. };
  298. const getStyles = (theme: GrafanaTheme2) => {
  299. return {
  300. title: css`
  301. margin: ${theme.spacing(4)} 0;
  302. `,
  303. infoText: css`
  304. font-size: ${theme.typography.size.sm};
  305. `,
  306. uploadWrapper: css`
  307. margin-left: 79px;
  308. `,
  309. row: css`
  310. display: flex;
  311. justify-content: space-between;
  312. width: 100%;
  313. flex-wrap: wrap;
  314. gap: ${theme.spacing(2)};
  315. & > div {
  316. flex: 1 1 340px;
  317. }
  318. `,
  319. footerText: css`
  320. margin-bottom: ${theme.spacing(2)};
  321. `,
  322. licenseCard: css`
  323. background: url('/public/img/licensing/card-bg-${theme.isLight ? 'light' : 'dark'}.svg') center no-repeat;
  324. background-size: cover;
  325. `,
  326. message: css`
  327. height: 70px;
  328. a {
  329. color: ${theme.colors.text.link};
  330. &:hover {
  331. text-decoration: underline;
  332. }
  333. }
  334. svg {
  335. margin-right: ${theme.spacing(0.5)};
  336. }
  337. `,
  338. };
  339. };
  340. type PageAlertProps = {
  341. state?: CardState;
  342. message?: string;
  343. title: string;
  344. orgSlug: string;
  345. licenseId: string;
  346. };
  347. const PageAlert = ({ state, message, title, orgSlug, licenseId }: PageAlertProps) => {
  348. const styles = useStyles2(getPageAlertStyles);
  349. if (!state) {
  350. return null;
  351. }
  352. return (
  353. <Alert title={title} severity={state || undefined}>
  354. <div className={styles.container}>
  355. <div>
  356. <p>{message}</p>
  357. <a
  358. className={styles.link}
  359. href={'https://grafana.com/docs/grafana/latest/enterprise/license/license-restrictions/'}
  360. target="_blank"
  361. rel="noopener noreferrer"
  362. >
  363. Learn about Enterprise licenses
  364. </a>
  365. </div>
  366. <CustomerSupportButton orgSlug={orgSlug} licenseId={licenseId} />
  367. </div>
  368. </Alert>
  369. );
  370. };
  371. const getPageAlertStyles = (theme: GrafanaTheme2) => {
  372. return {
  373. container: css`
  374. display: flex;
  375. align-items: flex-start;
  376. justify-content: space-between;
  377. width: 100%;
  378. `,
  379. link: css`
  380. font-size: ${theme.typography.bodySmall.fontSize};
  381. text-decoration: underline;
  382. color: ${theme.colors.text.secondary};
  383. &:hover {
  384. color: ${theme.colors.text.primary};
  385. }
  386. `,
  387. };
  388. };