diff --git a/daikoku/app/audit/audit.scala b/daikoku/app/audit/audit.scala index 8e8d75709..3a0ed17a6 100644 --- a/daikoku/app/audit/audit.scala +++ b/daikoku/app/audit/audit.scala @@ -9,6 +9,9 @@ import akka.kafka.ProducerSettings import akka.stream.scaladsl.{Flow, Keep, Sink, Source} import akka.stream.{OverflowStrategy, QueueOfferResult} import akka.{Done, NotUsed} +import cats.data.EitherT +import controllers.AppError +import diffson.DiffOps import fr.maif.otoroshi.daikoku.audit.config.{ElasticAnalyticsConfig, Webhook} import fr.maif.otoroshi.daikoku.domain._ import fr.maif.otoroshi.daikoku.env.Env @@ -663,6 +666,7 @@ class ElasticWritesAnalytics(config: ElasticAnalyticsConfig, env: Env) { private def urlFromPath(path: String): String = s"${config.clusterUri}$path" private val index: String = config.index.getOrElse("otoroshi-events") private val `type`: String = config.`type`.getOrElse("event") + private val searchUri = urlFromPath(s"/$index*/_search") private implicit val mat = env.defaultMaterializer private def url(url: String): WSRequest = { @@ -788,6 +792,66 @@ class ElasticWritesAnalytics(config: ElasticAnalyticsConfig, env: Env) { .runWith(Sink.ignore) .map(_ => ()) } + + def query(query: JsObject)(implicit ec: ExecutionContext): EitherT[Future, AppError, JsValue] = { + if (logger.isDebugEnabled) logger.debug(s"Query to Elasticsearch: $searchUri") + if (logger.isDebugEnabled) logger.debug(s"Query to Elasticsearch: ${Json.prettyPrint(query)}") + + EitherT(url(searchUri) + .addHttpHeaders(config.headers.toSeq: _*) + .post(query) + .map { resp => + resp.status match { + case 200 => Right[AppError, JsValue](resp.json) + case _ => + Left[AppError, JsValue](AppError.InternalServerError(s"Error during es request: \n * ${resp.body}, \nquery was \n * $query")) + } + }) + } +} + +class ElasticReadsAnalytics(config: ElasticAnalyticsConfig, env: Env) { + + lazy val logger = Logger("audit-reads-elastic") + + private def urlFromPath(path: String): String = s"${config.clusterUri}$path" + private val index: String = config.index.getOrElse("otoroshi-events") + private val `type`: String = config.`type`.getOrElse("event") + private val searchUri = urlFromPath(s"/$index*/_search") + private implicit val mat = env.defaultMaterializer + + private def url(url: String): WSRequest = { + val builder = env.wsClient.url(url) + authHeader() + .fold(builder) { h => + builder.withHttpHeaders("Authorization" -> h) + } + .addHttpHeaders(config.headers.toSeq: _*) + } + + private def authHeader(): Option[String] = { + for { + user <- config.user + password <- config.password + } yield + s"Basic ${Base64.getEncoder.encodeToString(s"$user:$password".getBytes())}" + } + + def query(query: JsObject)(implicit ec: ExecutionContext): EitherT[Future, AppError, JsValue] = { + if (logger.isDebugEnabled) logger.debug(s"Query to Elasticsearch: $searchUri") + if (logger.isDebugEnabled) logger.debug(s"Query to Elasticsearch: ${Json.prettyPrint(query)}") + + EitherT(url(searchUri) + .addHttpHeaders(config.headers.toSeq: _*) + .post(query) + .map { resp => + resp.status match { + case 200 => Right[AppError, JsValue](resp.json) + case _ => + Left[AppError, JsValue](AppError.InternalServerError(s"Error during es request: \n * ${resp.body}, \nquery was \n * $query")) + } + }) + } } class WebHookAnalytics(webhook: Webhook) { diff --git a/daikoku/app/controllers/ApiController.scala b/daikoku/app/controllers/ApiController.scala index 8a883a0e8..02ce4854f 100644 --- a/daikoku/app/controllers/ApiController.scala +++ b/daikoku/app/controllers/ApiController.scala @@ -5,12 +5,14 @@ import akka.http.scaladsl.util.FastFuture import akka.stream.Materializer import akka.stream.scaladsl.{Flow, JsonFraming, Sink, Source} import akka.util.ByteString +import cats.Id import cats.data.EitherT import cats.implicits.{catsSyntaxOptionId, toTraverseOps} import controllers.AppError import controllers.AppError._ import fr.maif.otoroshi.daikoku.actions.{DaikokuAction, DaikokuActionContext, DaikokuActionMaybeWithGuest} import fr.maif.otoroshi.daikoku.audit.AuditTrailEvent +import fr.maif.otoroshi.daikoku.audit.config.ElasticAnalyticsConfig import fr.maif.otoroshi.daikoku.ctrls.authorizations.async._ import fr.maif.otoroshi.daikoku.domain.NotificationAction.{ApiAccess, ApiSubscriptionDemand} import fr.maif.otoroshi.daikoku.domain.UsagePlanVisibility.Private @@ -4278,4 +4280,33 @@ class ApiController( value.merge } } + + def getApiSubscriptionsUsage(teamId: String) = + DaikokuAction.async(parse.json) { ctx => + TeamAdminOnly(AuditTrailEvent(s"@{user.name} has accessed to subscription usage for his team @{team.id}"))(teamId, ctx) { team => + + val subsIds = (ctx.request.body \ "subscriptions").as[JsArray] + + for { + subscriptions <- env.dataStore.apiSubscriptionRepo.forTenant(ctx.tenant).find(Json.obj("_id" -> Json.obj("$in" -> subsIds))) + planIds = subscriptions.map(_.plan.asJson).distinct + plans <- env.dataStore.usagePlanRepo.forTenant(ctx.tenant).find(Json.obj("_id" -> Json.obj("$in" -> JsArray(planIds)))) + test = subscriptions.groupBy(sub => sub.plan).toSeq + r <- Future.sequence(test.map { case (planId, subs) => getOtoroshiUsage(subs, plans.find(_.id == planId))(ctx.tenant)}) + } yield Ok(JsArray(r.flatMap(_.value))) + + } + } + + private def getOtoroshiUsage(subscriptions: Seq[ApiSubscription], plan: Option[UsagePlan])(implicit tenant: Tenant): Future[JsArray] = { + + val value1: EitherT[Future, JsArray, JsArray] = plan match { + case Some(value) => for { + otoroshi <- EitherT.fromOption[Future](tenant.otoroshiSettings.find(oto => value.otoroshiTarget.exists(_.otoroshiSettings == oto.id)), Json.arr()) + usages <- otoroshiClient.getSubscriptionLastUsage(subscriptions)(otoroshi, tenant) + } yield usages + case None => EitherT.pure[Future, JsArray](Json.arr()) + } + value1.merge + } } diff --git a/daikoku/app/domain/SchemaDefinition.scala b/daikoku/app/domain/SchemaDefinition.scala index 566e83bfa..dd3c7e1c5 100644 --- a/daikoku/app/domain/SchemaDefinition.scala +++ b/daikoku/app/domain/SchemaDefinition.scala @@ -174,7 +174,8 @@ object SchemaDefinition { lazy val OtoroshiSettingsType = deriveObjectType[(DataStore, DaikokuActionContext[JsValue]), OtoroshiSettings]( ObjectTypeDescription("Settings to communicate with an instance of Otoroshi"), - ReplaceField("id", Field("id", StringType, resolve = _.value.id.value)) + ReplaceField("id", Field("id", StringType, resolve = _.value.id.value)), + ReplaceField("elasticConfig", Field("elasticConfig", OptionType(ElasticAnalyticsConfigType), resolve = _.value.elasticConfig)) ) lazy val MailerSettingsType: InterfaceType[(DataStore, DaikokuActionContext[JsValue]), MailerSettings] = InterfaceType( "MailerSettings", diff --git a/daikoku/app/domain/json.scala b/daikoku/app/domain/json.scala index 97a557369..2df3549d1 100644 --- a/daikoku/app/domain/json.scala +++ b/daikoku/app/domain/json.scala @@ -108,7 +108,8 @@ object json { .getOrElse("admin-api-apikey-id"), clientSecret = (json \ "clientSecret") .asOpt[String] - .getOrElse("admin-api-apikey-sectet") + .getOrElse("admin-api-apikey-secret"), + elasticConfig = (json \ "elasticConfig").asOpt(ElasticAnalyticsConfig.format) ) ) } recover { @@ -120,7 +121,8 @@ object json { "url" -> o.url, "host" -> o.host, "clientId" -> o.clientId, - "clientSecret" -> o.clientSecret + "clientSecret" -> o.clientSecret, + "elasticConfig" -> o.elasticConfig.map(ElasticAnalyticsConfig.format.writes).getOrElse(JsNull).as[JsValue] ) } @@ -480,7 +482,9 @@ object json { Try { JsSuccess(OtoroshiSettingsId(json.as[String])) } recover { - case e => JsError(e.getMessage) + case e => + AppLogger.error(e.getMessage, e) + JsError(e.getMessage) } get override def writes(o: OtoroshiSettingsId): JsValue = JsString(o.value) diff --git a/daikoku/app/domain/tenantEntities.scala b/daikoku/app/domain/tenantEntities.scala index 0499f78ae..f5bfa7083 100644 --- a/daikoku/app/domain/tenantEntities.scala +++ b/daikoku/app/domain/tenantEntities.scala @@ -502,7 +502,8 @@ case class OtoroshiSettings(id: OtoroshiSettingsId, url: String, host: String, clientId: String = "admin-api-apikey-id", - clientSecret: String = "admin-api-apikey-secret") + clientSecret: String = "admin-api-apikey-secret", + elasticConfig: Option[ElasticAnalyticsConfig] = None) extends CanJson[OtoroshiSettings] { def asJson: JsValue = json.OtoroshiSettingsFormat.writes(this) def toUiPayload(): JsValue = { diff --git a/daikoku/app/utils/admin.scala b/daikoku/app/utils/admin.scala index 94b814de8..169554a45 100644 --- a/daikoku/app/utils/admin.scala +++ b/daikoku/app/utils/admin.scala @@ -223,6 +223,7 @@ abstract class AdminApiController[Of, Id <: ValueType]( } def findById(id: String) = DaikokuApiAction.async { ctx => + println("hi") val notDeleted: Boolean = ctx.request.queryString.get("notDeleted").exists(_ == "true") notDeleted match { diff --git a/daikoku/app/utils/otoroshi.scala b/daikoku/app/utils/otoroshi.scala index 4b7a89828..32e24f055 100644 --- a/daikoku/app/utils/otoroshi.scala +++ b/daikoku/app/utils/otoroshi.scala @@ -2,11 +2,16 @@ package fr.maif.otoroshi.daikoku.utils import akka.http.scaladsl.util.FastFuture import akka.stream.Materializer +import cats.data.EitherT +import cats.implicits.catsSyntaxOptionId import controllers.AppError import controllers.AppError.OtoroshiError +import fr.maif.otoroshi.daikoku.audit.{ElasticReadsAnalytics, ElasticWritesAnalytics} +import fr.maif.otoroshi.daikoku.audit.config.ElasticAnalyticsConfig import fr.maif.otoroshi.daikoku.domain.json.ActualOtoroshiApiKeyFormat -import fr.maif.otoroshi.daikoku.domain.{ActualOtoroshiApiKey, OtoroshiSettings} +import fr.maif.otoroshi.daikoku.domain.{ActualOtoroshiApiKey, ApiSubscription, OtoroshiSettings, Tenant, json} import fr.maif.otoroshi.daikoku.env.Env +import fr.maif.otoroshi.daikoku.logger.AppLogger import play.api.libs.json._ import play.api.libs.ws.{WSAuthScheme, WSRequest} import play.api.mvc._ @@ -285,4 +290,78 @@ class OtoroshiClient(env: Env) { } } } + + def getSubscriptionLastUsage(subscriptions: Seq[ApiSubscription])(implicit otoroshiSettings: OtoroshiSettings, tenant: Tenant): EitherT[Future, JsArray, JsArray] = { + otoroshiSettings.elasticConfig match { + case Some(config) => + new ElasticReadsAnalytics(config, env) + .query(Json.obj( + "query" -> Json.obj( + "bool" -> Json.obj( + "filter" -> Json.arr( + Json.obj("terms" -> Json.obj( + "identity.identity" -> JsArray(subscriptions.map(_.apiKey.clientId).map(JsString)) + )) + ) + ) + ), + "aggs" -> Json.obj( + "lastUsages" -> Json.obj( + "terms" -> Json.obj( + "field" -> "identity.identity" + ), + "aggs" -> Json.obj( + "latest" -> Json.obj( + "top_hits" -> Json.obj( + "size" -> 1, + "sort" -> Json.arr(Json.obj( + "@timestamp" -> Json.obj( + "order" -> "desc" + ) + )) + ) + ) + ) + )), + "size" -> 0 + )) + .map(resp => { + val buckets = (resp \ "aggregations" \ "lastUsages" \ "buckets").as[JsArray] + JsArray(buckets.value.map(agg => { + val key = (agg \ "key").as[String] + val lastUsage = (agg \ "latest" \ "hits" \ "hits").as[JsArray].value.head + val date = (lastUsage \ "_source" \ "@timestamp").as[JsValue] + + Json.obj( + "clientName" -> key, + "date" -> date, + "subscription" -> subscriptions.find(_.apiKey.clientId == key).map(_.id.asJson).getOrElse(JsNull).as[JsValue] + ) + })) + }) + .leftMap(e => { + AppLogger.error(e.getErrorMessage()) + Json.arr() + }) + case None => for { + elasticConfig <- EitherT.fromOptionF(getElasticConfig(), Json.arr()) + updatedSettings = otoroshiSettings.copy(elasticConfig = elasticConfig.some) + updatedTenant = tenant.copy(otoroshiSettings = tenant.otoroshiSettings.filter(_.id != otoroshiSettings.id) + updatedSettings) + _ <- EitherT.liftF(env.dataStore.tenantRepo.save(updatedTenant)) + r <- getSubscriptionLastUsage(subscriptions)(updatedSettings, updatedTenant) + } yield r + } + } + + private def getElasticConfig()(implicit otoroshiSettings: OtoroshiSettings): Future[Option[ElasticAnalyticsConfig]] = { + client(s"/api/globalconfig").get().map(resp => { + if (resp.status == 200) { + val config = resp.json.as[JsObject] + val elasticReadConfig = (config \ "elasticReadsConfig").asOpt(ElasticAnalyticsConfig.format) + elasticReadConfig + } else { + None + } + }) + } } diff --git a/daikoku/conf/routes b/daikoku/conf/routes index e41c0635b..275599398 100644 --- a/daikoku/conf/routes +++ b/daikoku/conf/routes @@ -114,6 +114,7 @@ DELETE /api/teams/:teamId/subscriptions/_clean fr.maif.otoroshi.daiko POST /api/teams/:teamId/subscriptions/:id/_rotation fr.maif.otoroshi.daikoku.ctrls.ApiController.toggleApiKeyRotation(teamId, id) POST /api/teams/:teamId/subscriptions/:id/_refresh fr.maif.otoroshi.daikoku.ctrls.ApiController.regenerateApiKeySecret(teamId, id) POST /api/subscriptions/_init fr.maif.otoroshi.daikoku.ctrls.ApiController.initSubscriptions() +POST /api/teams/:teamId/subscriptions/_lastUsage fr.maif.otoroshi.daikoku.ctrls.ApiController.getApiSubscriptionsUsage(teamId) POST /api/apis/_init fr.maif.otoroshi.daikoku.ctrls.ApiController.initApis() GET /api/teams/:teamId/subscription/:id/informations fr.maif.otoroshi.daikoku.ctrls.ApiController.getSubscriptionInformations(teamId, id) GET /api/teams/:teamId/apis/:apiId/:version/subscriptions fr.maif.otoroshi.daikoku.ctrls.ApiController.getApiSubscriptions(teamId, apiId, version) diff --git a/daikoku/javascript/src/components/backoffice/apis/TeamApiSubscriptions.tsx b/daikoku/javascript/src/components/backoffice/apis/TeamApiSubscriptions.tsx index 957c0def7..2be0ee357 100644 --- a/daikoku/javascript/src/components/backoffice/apis/TeamApiSubscriptions.tsx +++ b/daikoku/javascript/src/components/backoffice/apis/TeamApiSubscriptions.tsx @@ -9,7 +9,7 @@ import { ModalContext } from '../../../contexts'; import { CustomSubscriptionData } from '../../../contexts/modals/SubscriptionMetadataModal'; import { I18nContext } from '../../../core'; import * as Services from '../../../services'; -import { IApi, IState, ITeamSimple, IUsagePlan, isError } from "../../../types"; +import { IApi, IState, ISubscriptionWithApiInfo, ITeamSimple, IUsagePlan, isError } from "../../../types"; import { SwitchButton, Table, TableRef } from '../../inputs'; import { api as API, @@ -37,7 +37,7 @@ type LimitedPlan = { type: string } -type ApiSubscriptionGql = { +interface IApiSubscriptionGql { _id: string apiKey: { clientName: string @@ -77,6 +77,11 @@ type ApiSubscriptionGql = { } } } + +interface IApiSubscriptionGqlWithUsage extends IApiSubscriptionGql { + lastUsage?: number +} + export const TeamApiSubscriptions = ({ api }: TeamApiSubscriptionsProps) => { const currentTeam = useSelector((s) => s.context.currentTeam); @@ -89,22 +94,69 @@ export const TeamApiSubscriptions = ({ api }: TeamApiSubscriptionsProps) => { const { confirm, openFormModal, openSubMetadataModal, } = useContext(ModalContext); const plansQuery = useQuery(['plans'], () => Services.getAllPlanOfApi(api.team, api._id, api.currentVersion)) + const subscriptionsQuery = useQuery(['subscriptions'], () => client!.query<{ apiApiSubscriptions: Array; }>({ + query: Services.graphql.getApiSubscriptions, + fetchPolicy: "no-cache", + variables: { + apiId: api._id, + teamId: currentTeam._id, + version: api.currentVersion + } + }).then(({ data: { apiApiSubscriptions } }) => { + if (!filters || (!filters.tags.length && !Object.keys(filters.metadata).length && !filters.clientIds.length)) { + return apiApiSubscriptions + } else { + const filterByMetadata = (subscription: IApiSubscriptionGql) => { + const meta = { ...(subscription.metadata || {}), ...(subscription.customMetadata || {}) }; + + return !Object.keys(meta) || (!filters.metadata.length || filters.metadata.every(item => { + const value = meta[item.key] + return value && value.includes(item.value) + })) + } + + const filterByTags = (subscription: IApiSubscriptionGql) => { + return filters.tags.every(tag => subscription.tags.includes(tag)) + } + + const filterByClientIds = (subscription: IApiSubscriptionGql) => { + return filters.clientIds.includes(subscription.apiKey.clientId) + } + + return apiApiSubscriptions + .filter(filterByMetadata) + .filter(filterByTags) + .filter(filterByClientIds) + } + }) + ) + const lastUsagesQuery = useQuery({ + queryKey: ['usages'], + queryFn: () => Services.getSubscriptionsLastUsages(api.team, subscriptionsQuery.data?.map(s => s._id) || []) + .then(lastUsages => { + if (isError(lastUsages)) { + return subscriptionsQuery.data as IApiSubscriptionGqlWithUsage[] + } else { + return (subscriptionsQuery.data ?? []).map(s => ({...s, lastUsage: lastUsages.find(u => u.subscription === s._id)?.date} as IApiSubscriptionGqlWithUsage)) + }}), + enabled: !!subscriptionsQuery.data && !isError(subscriptionsQuery.data) + }) useEffect(() => { document.title = `${currentTeam.name} - ${translate('Subscriptions')}`; }, []); useEffect(() => { - if (api) { + if (api && lastUsagesQuery.data) { tableRef.current?.update() } - }, [api]) + }, [api, lastUsagesQuery.data]) useEffect(() => { tableRef.current?.update() }, [filters]) - const columnHelper = createColumnHelper() + const columnHelper = createColumnHelper() const columns = (usagePlans) => [ columnHelper.accessor(row => row.adminCustomName || row.apiKey.clientName, { id: 'adminCustomName', @@ -183,7 +235,25 @@ export const TeamApiSubscriptions = ({ api }: TeamApiSubscriptionsProps) => { enableColumnFilter: false, header: translate('Created at'), meta: { style: { textAlign: 'left' } }, - cell: (info) => formatDate(info.getValue(), language), + cell: (info) => { + const date = info.getValue() + if (!!date) { + return formatDate(date, language) + } + return translate('N/A') + }, + }), + columnHelper.accessor('lastUsage', { + enableColumnFilter: false, + header: translate('apisubscription.lastUsage.label'), + meta: { style: { textAlign: 'left' } }, + cell: (info) => { + const date = info.getValue() + if (!!date) { + return formatDate(date, language) + } + return translate('N/A') + }, }), columnHelper.display({ header: translate('Actions'), @@ -211,7 +281,7 @@ export const TeamApiSubscriptions = ({ api }: TeamApiSubscriptionsProps) => { }), ] - const updateMeta = (sub: ApiSubscriptionGql) => openSubMetadataModal({ + const updateMeta = (sub: IApiSubscriptionGql) => openSubMetadataModal({ save: (updates: CustomSubscriptionData) => { Services.updateSubscription(currentTeam, { ...sub, ...updates }) .then(() => tableRef.current?.update()); @@ -225,7 +295,7 @@ export const TeamApiSubscriptions = ({ api }: TeamApiSubscriptionsProps) => { .find(p => sub.plan._id === p._id)! }); - const regenerateSecret = (sub: ApiSubscriptionGql) => { + const regenerateSecret = (sub: IApiSubscriptionGql) => { const plan = sub.plan @@ -244,7 +314,7 @@ export const TeamApiSubscriptions = ({ api }: TeamApiSubscriptionsProps) => { }); }; - const deleteSubscription = (sub: ApiSubscriptionGql) => { + const deleteSubscription = (sub: IApiSubscriptionGql) => { confirm({ title: translate('api.delete.subscription.form.title'), message: translate({ key: 'api.delete.subscription.message', replacements: [sub.team.name, sub.plan.customName ? sub.plan.customName : sub.plan.type] }), @@ -332,41 +402,11 @@ export const TeamApiSubscriptions = ({ api }: TeamApiSubscriptionsProps) => { defaultSort="name" columns={columns(usagePlans)} fetchItems={() => { - return client!.query<{ apiApiSubscriptions: Array; }>({ - query: Services.graphql.getApiSubscriptions, - fetchPolicy: "no-cache", - variables: { - apiId: api._id, - teamId: currentTeam._id, - version: api.currentVersion - } - }).then(({ data: { apiApiSubscriptions } }) => { - if (!filters || (!filters.tags.length && !Object.keys(filters.metadata).length && !filters.clientIds.length)) { - return apiApiSubscriptions - } else { - const filterByMetadata = (subscription: ApiSubscriptionGql) => { - const meta = { ...(subscription.metadata || {}), ...(subscription.customMetadata || {}) }; - - return !Object.keys(meta) || (!filters.metadata.length || filters.metadata.every(item => { - const value = meta[item.key] - return value && value.includes(item.value) - })) - } - - const filterByTags = (subscription: ApiSubscriptionGql) => { - return filters.tags.every(tag => subscription.tags.includes(tag)) - } - - const filterByClientIds = (subscription: ApiSubscriptionGql) => { - return filters.clientIds.includes(subscription.apiKey.clientId) - } - - return apiApiSubscriptions - .filter(filterByMetadata) - .filter(filterByTags) - .filter(filterByClientIds) - } - }) + if (lastUsagesQuery.isLoading || lastUsagesQuery.error) { + return [] + } else { + return lastUsagesQuery.data ?? [] + } }} ref={tableRef} /> diff --git a/daikoku/javascript/src/components/frontend/team/MyHome.tsx b/daikoku/javascript/src/components/frontend/team/MyHome.tsx index 0bac74536..b3d1bb90f 100644 --- a/daikoku/javascript/src/components/frontend/team/MyHome.tsx +++ b/daikoku/javascript/src/components/frontend/team/MyHome.tsx @@ -7,7 +7,7 @@ import { useNavigate} from 'react-router-dom'; import { I18nContext, updateTeam } from '../../../core'; import * as Services from '../../../services'; import { converter } from '../../../services/showdown'; -import { IApiWithAuthorization, isError, IState, ITenant, IUserSimple } from '../../../types'; +import { IApiWithAuthorization, isError, IState, ITenant, IUsagePlan, IUserSimple } from '../../../types'; import { ApiList } from './ApiList'; import { api as API, CanIDoAction, manage, Spinner } from '../../utils'; diff --git a/daikoku/javascript/src/components/utils/formatters.tsx b/daikoku/javascript/src/components/utils/formatters.tsx index da2003fa5..b9fb55473 100644 --- a/daikoku/javascript/src/components/utils/formatters.tsx +++ b/daikoku/javascript/src/components/utils/formatters.tsx @@ -182,7 +182,7 @@ export const teamPermissions = { user: 'User', }; -export const formatDate = (date: any, language: any, format = 'l LT') => { +export const formatDate = (date: number|string, language: string, format = 'l LT') => { moment.locale(language); return moment(date).format(format); }; diff --git a/daikoku/javascript/src/locales/en/translation.json b/daikoku/javascript/src/locales/en/translation.json index 4c65f6170..1a27aca77 100644 --- a/daikoku/javascript/src/locales/en/translation.json +++ b/daikoku/javascript/src/locales/en/translation.json @@ -1368,5 +1368,7 @@ "team_apikey_aggregatePlans_title": "Aggregated API keys", "constraints.plan.custom-name.one-of.environment": "The plan name must match one of the available environments", "aggregated.apikey.badge.title": "Aggregated API key", - "aggregated.apikey.badge.apikey.name": "API key custom name" + "aggregated.apikey.badge.apikey.name": "API key custom name", + "apisubscription.lastUsage.label": "Last usage", + "N/A": "N/A" } \ No newline at end of file diff --git a/daikoku/javascript/src/locales/fr/translation.json b/daikoku/javascript/src/locales/fr/translation.json index 5355e834a..8f08fb764 100644 --- a/daikoku/javascript/src/locales/fr/translation.json +++ b/daikoku/javascript/src/locales/fr/translation.json @@ -1372,5 +1372,7 @@ "team_apikey_aggregatePlans_title": "Clés d'API agregées", "constraints.plan.custom-name.one-of.environment": "Le nom du plan doit correspondre à l'un des environnements disponibles", "aggregated.apikey.badge.title": "Clé d'API aggrégée", - "aggregated.apikey.badge.apikey.name": "Nom personnalisée de la clé" + "aggregated.apikey.badge.apikey.name": "Nom personnalisée de la clé", + "apisubscription.lastUsage.label": "Last usage", + "N/A": "N/A" } \ No newline at end of file diff --git a/daikoku/javascript/src/services/index.ts b/daikoku/javascript/src/services/index.ts index c9620882f..d1b834f99 100644 --- a/daikoku/javascript/src/services/index.ts +++ b/daikoku/javascript/src/services/index.ts @@ -39,6 +39,7 @@ import { IApiKey, IOtoroshiApiKey, } from '../types/api'; +import { base64 } from 'js-md5'; const HEADERS = { Accept: 'application/json', @@ -1945,3 +1946,14 @@ export const cancelProcess = (teamId: string, demandId: string) => export const fetchInvoices = (teamId: string, apiId: string, planId: string, callback: string) => customFetch(`/api/teams/${teamId}/apis/${apiId}/plan/${planId}/invoices?callback=${callback}`); + +export type ILastUsage = { + clientName: string + date: number + subscription: string +} +export const getSubscriptionsLastUsages = (teamId: string, subscriptions: Array): PromiseWithError> => + customFetch(`/api/teams/${teamId}/subscriptions/_lastUsage`, { + method: 'POST', + body: JSON.stringify({subscriptions}) + })