diff --git a/README.md b/README.md index 26519a296..448d2c1b3 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,21 @@ It provides deep visibility into infrastructure costs, automated optimization re +
+
+ +
+
+ + + 🔖 NEW 🔖
Cloud costs under control? Time to go beyond – into AI. +
+
OptScale AI covers it all – cost control, security and guardrails, and full visibility over every AI prompt, model, and agent. + + Try free at [optscale.ai](https://optscale.ai) +
+ +

diff --git a/documentation/images/OptScale-AI-description-ReadMe-GitHub.png b/documentation/images/OptScale-AI-description-ReadMe-GitHub.png new file mode 100644 index 000000000..867ce40f0 Binary files /dev/null and b/documentation/images/OptScale-AI-description-ReadMe-GitHub.png differ diff --git a/insider/insider_worker/http_client/client.py b/insider/insider_worker/http_client/client.py index a6d64842b..27576df10 100644 --- a/insider/insider_worker/http_client/client.py +++ b/insider/insider_worker/http_client/client.py @@ -35,12 +35,13 @@ def request(self, url, method): response_body = None # pylint: disable=E1101 if response.status_code != requests.codes.no_content: - if 'application/json' in response.headers['Content-Type']: + content_type = response.headers.get('Content-Type', '') + if 'application/json' in content_type: response_body = json.loads( response.content.decode('utf-8')) - if 'text/plain' in response.headers['Content-Type']: + elif 'text/plain' in content_type: response_body = response.content.decode() - if 'application/octet-stream' in response.headers['Content-Type']: + else: response_body = response.content return response.status_code, response_body diff --git a/insider/insider_worker/main.py b/insider/insider_worker/main.py index 260f8c4ce..e144fe683 100644 --- a/insider/insider_worker/main.py +++ b/insider/insider_worker/main.py @@ -21,6 +21,7 @@ LOG = get_logger(__name__) TASK_EXCHANGE = Exchange(EXCHANGE_NAME, type='direct') TASK_QUEUE = Queue(QUEUE_NAME, TASK_EXCHANGE, routing_key=QUEUE_NAME) +DISCOVERIES_THRESHOLD = 43200 # 12 hours in seconds class InsiderWorker(ConsumerMixin): @@ -42,13 +43,28 @@ def discoveries(self): def get_consumers(self, consumer, channel): return [consumer(queues=[TASK_QUEUE], accept=['json'], - callbacks=[self.process_task], prefetch_count=10)] + callbacks=[self.process_task], prefetch_count=1)] + + def get_last_discovery_ts(self, cloud_type): + discoveries = self.discoveries.find( + {'cloud_type': cloud_type, 'completed_at': {'$ne': 0}} + ).sort( + [('completed_at', -1)]).limit(1) + try: + discovery = next(discoveries) + return discovery.get('started_at', 0) + except StopIteration: + return 0 def _process_task(self, task): start_process_time = int(datetime.now(tz=timezone.utc).timestamp()) cloud_type = task.get('cloud_type') if not cloud_type: raise Exception('Invalid task received: {}'.format(task)) + last_discovery_ts = self.get_last_discovery_ts(cloud_type) + if last_discovery_ts + DISCOVERIES_THRESHOLD >= start_process_time: + LOG.info('Skipping task for %s by threshold', cloud_type) + return discovery_id = self.discoveries.insert_one({ 'cloud_type': cloud_type, 'started_at': start_process_time, @@ -56,7 +72,7 @@ def _process_task(self, task): }).inserted_id get_processor_class(cloud_type)( - self.mongo_client, self.config_cl).process_prices() + self.mongo_client, self.config_cl).process_prices(last_discovery_ts) end_process_time = int(datetime.now(tz=timezone.utc).timestamp()) self.discoveries.update_one( diff --git a/insider/insider_worker/migrations/20260618150755_last_seen_currency_index.py b/insider/insider_worker/migrations/20260618150755_last_seen_currency_index.py new file mode 100644 index 000000000..58786e92d --- /dev/null +++ b/insider/insider_worker/migrations/20260618150755_last_seen_currency_index.py @@ -0,0 +1,53 @@ +import logging +from insider.insider_worker.migrations.base import BaseMigration + +NEW_INDEXES = { + 'CurrencyLastSeen': ['currencyCode', 'last_seen'] +} +OLD_INDEXES = { + 'LastSeen': ['last_seen'] +} +LOG = logging.getLogger(__name__) + + +class Migration(BaseMigration): + def get_indexes(self): + return [x['name'] for x in self.azure_prices.list_indexes()] + + def upgrade(self): + existing_indexes = self.get_indexes() + for index_name, index_fields in NEW_INDEXES.items(): + if index_name in existing_indexes: + LOG.info(f'Index {index_name} already exists') + continue + LOG.info(f'Creating index {index_name}') + self.azure_prices.create_index( + [(f, 1) for f in index_fields], + name=index_name, + background=True + ) + for index_name, index_fields in OLD_INDEXES.items(): + if index_name in existing_indexes: + LOG.info(f'Dropping index {index_name}') + self.azure_prices.drop_index(index_name) + else: + LOG.info(f'Index {index_name} doesn\'t exist') + + def downgrade(self): + existing_indexes = self.get_indexes() + for index_name, index_fields in OLD_INDEXES.items(): + if index_name in existing_indexes: + LOG.info(f'Index {index_name} already exists') + continue + LOG.info(f'Creating index {index_name}') + self.azure_prices.create_index( + [(f, 1) for f in index_fields], + name=index_name, + background=True + ) + for index_name, index_fields in NEW_INDEXES.items(): + if index_name in existing_indexes: + LOG.info(f'Dropping index {index_name}') + self.azure_prices.drop_index(index_name) + else: + LOG.info(f'Index {index_name} doesn\'t exist') diff --git a/insider/insider_worker/processors/azure.py b/insider/insider_worker/processors/azure.py index 5b02dc75f..cf0f5a54d 100644 --- a/insider/insider_worker/processors/azure.py +++ b/insider/insider_worker/processors/azure.py @@ -15,8 +15,8 @@ ACTIVITIES_EXCHANGE_NAME = 'activities-tasks' ACTIVITIES_EXCHANGE = Exchange(ACTIVITIES_EXCHANGE_NAME, type='topic') LOG = get_logger(__name__) -PRICES_PER_REQUEST = 100 PRICES_COUNT_TO_LOG = 1000 +CHINA_CURRENCY_CODE = 'CNY' class AzurePriceProcessor(BasePriceProcessor): @@ -35,16 +35,6 @@ def discoveries(self): def prices(self): return self.mongo_client.insider.azure_prices - def get_last_discovery(self): - discoveries = self.discoveries.find( - {'cloud_type': self.CLOUD_TYPE, 'completed_at': {'$ne': 0}} - ).sort( - [('completed_at', -1)]).limit(1) - try: - return next(discoveries) - except StopIteration: - return {} - @staticmethod def unique_values(price): return tuple(price.get(p) for p in AzurePriceProcessor.UNIQUE_FIELDS) @@ -82,42 +72,39 @@ def _get_currencies_list(self): currencies = set(map(lambda x: x['currency'], orgs['organizations'])) return list(currencies) - def _process_global_prices(self, http_client, old_prices_map): - LOG.info('Start processing Azure Global prices') - for currency in self._get_currencies_list(): - LOG.info('Processing Azure prices for currency: %s', currency) - processed_keys = {} - prices_counter = 0 - - next_page = 'https://prices.azure.com/api/retail/prices' - next_page += '?currencyCode=%s' % currency - while True: - if prices_counter % PRICES_COUNT_TO_LOG == 0: - LOG.info('Total number of prices got from ' - 'cloud: %s', prices_counter) - try: - code, response = http_client.get(next_page) - except SSLError: - LOG.error('Getting Azure prices failed with SSL ' - 'verification error. Will try to get prices' - 'without SSL verification') - self.send_sslerror_service_email() - http_client = Client(verify=False) - code, response = http_client.get(next_page) - items = response.get('Items', []) - new_prices_map = {self.unique_values(p): p for p in items} - self.update_price_records(new_prices_map, old_prices_map, - processed_keys) - new_url = response.get('NextPageLink') - if not new_url or new_url == next_page: - LOG.info('Total number of prices got from ' - 'cloud: %s', prices_counter) - break - next_page = new_url - prices_counter += response.get('Count', 0) - - def _process_china_prices(self, http_client, old_prices_map): - LOG.info('Start processing Azure China prices') + def _process_global_prices(self, http_client, old_prices_map, currency): + LOG.info('Processing Azure prices for currency: %s', currency) + processed_keys = {} + prices_counter = 0 + next_page = 'https://prices.azure.com/api/retail/prices' + next_page += '?currencyCode=%s' % currency + while True: + if prices_counter % PRICES_COUNT_TO_LOG == 0: + LOG.info('Total number of prices got from ' + 'cloud: %s', prices_counter) + try: + code, response = http_client.get(next_page) + except SSLError: + LOG.error('Getting Azure prices failed with SSL ' + 'verification error. Will try to get prices' + 'without SSL verification') + self.send_sslerror_service_email() + http_client = Client(verify=False) + code, response = http_client.get(next_page) + items = response.get('Items', []) + new_prices_map = {self.unique_values(p): p for p in items} + self.update_price_records(new_prices_map, old_prices_map, + processed_keys) + new_url = response.get('NextPageLink') + if not new_url or new_url == next_page: + LOG.info('Total number of prices got from ' + 'cloud: %s', prices_counter) + break + next_page = new_url + prices_counter += response.get('Count', 0) + + def _process_china_prices(self, http_client, old_prices_map, currency): + LOG.info('Start processing Azure China prices (%s)', currency) url = 'https://prices.azure.cn/api/retail/pricesheet/download?' \ 'api-version=2023-06-01-preview' _, response = http_client.get(url) @@ -130,17 +117,28 @@ def _process_china_prices(self, http_client, old_prices_map): LOG.info('Total number of prices got from cloud: %s', len(new_prices_map)) - def process_prices(self): - last_discovery = self.get_last_discovery() - old_prices = self.prices.find( - {'last_seen': {'$gte': last_discovery.get('started_at', 0)}}, - {k: 1 for k in self.UNIQUE_FIELDS + self.CHANGE_FIELDS + ['last_seen']} - ) - old_prices_map = {self.unique_values(p): p for p in old_prices} - + def process_prices(self, last_discovery_ts): http_client = Client() - self._process_global_prices(http_client, old_prices_map) - self._process_china_prices(http_client, old_prices_map) + process_func_map = { + CHINA_CURRENCY_CODE: self._process_china_prices + } + for currency in self._get_currencies_list(): + old_prices = self.prices.find( + { + 'last_seen': { + '$gte': last_discovery_ts + }, + 'currencyCode': currency + }, + { + k: 1 for k in + self.UNIQUE_FIELDS + self.CHANGE_FIELDS + ['last_seen'] + } + ) + old_prices_map = {self.unique_values(p): p for p in old_prices} + process_func = process_func_map.get( + currency, self._process_global_prices) + process_func(http_client, old_prices_map, currency) def update_price_records(self, new_prices_map, old_prices_map, processed_keys): diff --git a/insider/insider_worker/processors/base.py b/insider/insider_worker/processors/base.py index 468258cad..d962faaca 100644 --- a/insider/insider_worker/processors/base.py +++ b/insider/insider_worker/processors/base.py @@ -11,8 +11,5 @@ def discoveries(self): def prices(self): raise NotImplementedError() - def get_last_discovery(self): - raise NotImplementedError() - - def process_prices(self): + def process_prices(self, last_discovery_ts): raise NotImplementedError() diff --git a/ngui/ui/src/components/TopAlertWrapper/TopAlert.styles.ts b/ngui/ui/src/components/TopAlertWrapper/TopAlert.styles.ts index e76e02f4d..928a90aca 100644 --- a/ngui/ui/src/components/TopAlertWrapper/TopAlert.styles.ts +++ b/ngui/ui/src/components/TopAlertWrapper/TopAlert.styles.ts @@ -33,6 +33,13 @@ const useStyles = makeStyles()((theme) => ({ color: theme.palette.common.white, }, }, + promo: { + backgroundColor: "#2c67ce", + color: theme.palette.common.white, + ".close-alert-button": { + color: theme.palette.common.white, + }, + }, })); export default useStyles; diff --git a/ngui/ui/src/components/TopAlertWrapper/TopAlertWrapper.tsx b/ngui/ui/src/components/TopAlertWrapper/TopAlertWrapper.tsx index 2cb67eb76..e1aaeee16 100644 --- a/ngui/ui/src/components/TopAlertWrapper/TopAlertWrapper.tsx +++ b/ngui/ui/src/components/TopAlertWrapper/TopAlertWrapper.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo } from "react"; -import { Box } from "@mui/material"; +import { Box, Link } from "@mui/material"; import { render as renderGithubButton } from "github-buttons"; import { FormattedMessage, useIntl } from "react-intl"; import { useDispatch } from "react-redux"; @@ -7,7 +7,7 @@ import { useAllDataSources } from "hooks/coreData/useAllDataSources"; import { useGetToken } from "hooks/useGetToken"; import { useOrganizationInfo } from "hooks/useOrganizationInfo"; import { useRootData } from "hooks/useRootData"; -import { GITHUB_HYSTAX_OPTSCALE_REPO } from "urls"; +import { GITHUB_HYSTAX_OPTSCALE_REPO, OPTSCALE_AI } from "urls"; import { AZURE_TENANT, ENVIRONMENT } from "utils/constants"; import { SPACING_1 } from "utils/layouts"; import { updateOrganizationTopAlert as updateOrganizationTopAlertActionCreator } from "./actionCreators"; @@ -132,12 +132,14 @@ const TopAlertWrapper = ({ blacklistIds = [] }: TopAlertWrapperProps) => { }, { id: ALERT_TYPES.OPEN_SOURCE_ANNOUNCEMENT, + // Temporarily disabled — replaced by OPTSCALE_AI_PROMO_ANNOUNCEMENT below. + // To restore, revert condition back to: !isExistingUser && (!userId || organizationId) // isExistingUser — true only if user was logged in/visited optscale before. Set in migrations. // organizationId — wont be presented on initial load (so storedAlerts will be empty, so even if banner was closed, we would not know that, // so we need to wait for organizationId. But if user is not logged in — there also wont be organizationId, so we use next flag) // userId — presented after login // this check means "condition: not logged in new user (!isExistingUser && !userId) OR new user and we know organization id (!isExistingUser && organizationId)" - condition: !isExistingUser && (!userId || organizationId), + condition: false, getContent: () => ( { }, dataTestId: "top_alert_open_source_announcement", }, + { + id: ALERT_TYPES.OPTSCALE_AI_PROMO_ANNOUNCEMENT, + // isExistingUser — true only if user was logged in/visited optscale before. Set in migrations. + // organizationId — wont be presented on initial load (so storedAlerts will be empty, so even if banner was closed, we would not know that, + // so we need to wait for organizationId. But if user is not logged in — there also wont be organizationId, so we use next flag) + // userId — presented after login + // this check means "condition: not logged in new user (!isExistingUser && !userId) OR new user and we know organization id (!isExistingUser && organizationId)" + condition: !isExistingUser && (!userId || organizationId), + getContent: () => ( + + {chunks}, + br: () =>
, + link: (chunks) => ( + + {chunks} + + ), + }} + /> +
+ ), + type: "promo", + triggered: isTriggered(ALERT_TYPES.OPTSCALE_AI_PROMO_ANNOUNCEMENT), + onClose: () => { + updateOrganizationTopAlert({ id: ALERT_TYPES.OPTSCALE_AI_PROMO_ANNOUNCEMENT, closed: true }); + }, + dataTestId: "top_alert_optscale_ai_promo_announcement", + }, ]; }, [ storedAlerts, diff --git a/ngui/ui/src/components/TopAlertWrapper/constants.ts b/ngui/ui/src/components/TopAlertWrapper/constants.ts index 8c1deed34..d0fd228be 100644 --- a/ngui/ui/src/components/TopAlertWrapper/constants.ts +++ b/ngui/ui/src/components/TopAlertWrapper/constants.ts @@ -3,6 +3,7 @@ export const ALERT_TYPES = Object.freeze({ DATA_SOURCES_PROCEEDED: 3, OPEN_SOURCE_ANNOUNCEMENT: 4, INACTIVE_ORGANIZATION: 5, + OPTSCALE_AI_PROMO_ANNOUNCEMENT: 7, }); export const IS_EXISTING_USER = "isExistingUser"; diff --git a/ngui/ui/src/migrations.ts b/ngui/ui/src/migrations.ts index d2a5190b7..4ec4b64f6 100644 --- a/ngui/ui/src/migrations.ts +++ b/ngui/ui/src/migrations.ts @@ -4,7 +4,7 @@ import { RANGE_DATES } from "containers/RangePickerFormContainer/reducer"; import { millisecondsToSeconds } from "utils/datetime"; import { objectMap } from "utils/objects"; -export const CURRENT_VERSION = 15; +export const CURRENT_VERSION = 16; // When we modify storage structure, we will need to properly use migrations: // https://github.com/rt2zz/redux-persist/blob/master/docs/migrations.md @@ -133,6 +133,21 @@ const migrations = { ]) ); + return { + ...state, + alerts: newAlerts, + }; + }, + 16: (state) => { + // OPEN_SOURCE_ANNOUNCEMENT (github stars) is temporarily replaced by the OptScale AI promo banner. + const newAlerts = Object.fromEntries( + Object.entries(state.alerts).map(([orgId, payload]) => [ + orgId, + // OPEN_SOURCE_ANNOUNCEMENT = 4 + payload.filter(({ id }) => id !== 4), + ]) + ); + return { ...state, alerts: newAlerts, diff --git a/ngui/ui/src/translations/en-US/app.json b/ngui/ui/src/translations/en-US/app.json index b7890b55b..df9271395 100644 --- a/ngui/ui/src/translations/en-US/app.json +++ b/ngui/ui/src/translations/en-US/app.json @@ -1619,6 +1619,7 @@ "openPorts": "Open ports", "openShareSettings": "Open expenses export settings", "openSourceAnnouncement": "Please consider giving OptScale a Star on GitHub, it is 100% open-source. It would increase its visibility to others and expedite product development. Thank you!", + "optScaleAiPromoAnnouncement": "⭐️ NEW ⭐️ Cloud costs under control? Time to go beyond – into AI.

OptScale AI covers it all – cost control, security and guardrails, and full visibility over every AI prompt, model, and agent. Try free at optscale.ai", "optScalePrivacyPolicy": "OptScale Privacy Policy", "optScaleSlackIntegrationTitle": "OptScale Slack Integration", "optimal": "Optimal", diff --git a/ngui/ui/src/urls.ts b/ngui/ui/src/urls.ts index da3db28e6..d1efb34ed 100644 --- a/ngui/ui/src/urls.ts +++ b/ngui/ui/src/urls.ts @@ -604,6 +604,9 @@ export const GITHUB_HYSTAX_EXTRACT_LINKED_REPORTS = "https://github.com/hystax/o export const GITHUB_HYSTAX_OPTSCALE_REPO = "https://github.com/hystax/optscale"; export const PYPI_OPTSCALE_ARCEE = "https://pypi.org/project/optscale-arcee"; +// OptScale AI promo +export const OPTSCALE_AI = "https://optscale.ai/"; + // Nebius documentation export const NEBIUS_CREATE_SERVICE_ACCOUNT = "https://nebius.com/il/docs/iam/quickstart-sa#create-sa"; export const NEBIUS_CREATING_AUTHORIZED_KEYS = "https://nebius.com/il/docs/iam/operations/authorized-key/create";