diff --git a/docs/assets/stylesheets/extra.css b/docs/assets/stylesheets/extra.css index d42a74918..bf4ea92aa 100644 --- a/docs/assets/stylesheets/extra.css +++ b/docs/assets/stylesheets/extra.css @@ -804,7 +804,7 @@ body { display: inline-block; font-size: 17px; font-weight: 600; - line-height: 1.4rem; + /* line-height: 1.4rem; */ /*letter-spacing: -0.5px;*/ position: relative; left: -11px; @@ -866,7 +866,7 @@ body { } .md-sidebar--primary .md-nav__link, .md-sidebar--post .md-nav__link { - padding: 5px 15px 4px; + padding: 4px 15px 4px; margin-top: 0; } @@ -989,6 +989,10 @@ html .md-footer-meta.md-typeset a:is(:focus,:hover) { .md-nav--integrated>.md-nav__list>.md-nav__item--active .md-nav--secondary { margin-bottom: 0; } + + .md-nav--primary .md-nav__list { + padding-bottom: .2rem; + } } .md-typeset :where(ol, ul) { diff --git a/docs/blog/posts/state-of-cloud-gpu-2025.md b/docs/blog/posts/state-of-cloud-gpu-2025.md new file mode 100644 index 000000000..c689c9c4b --- /dev/null +++ b/docs/blog/posts/state-of-cloud-gpu-2025.md @@ -0,0 +1,146 @@ +--- +title: "The state of cloud GPUs in 2025: costs, performance, playbooks" +date: 2025-09-10 +description: "TBA" +slug: state-of-cloud-gpu-2025 +image: https://dstack.ai/static-assets/static-assets/images/cloud-gpu-providers.png +# categories: +# - Benchmarks +--- + +# The state of cloud GPUs in 2025: costs, performance, playbooks + +This is a practical map for teams renting GPUs — whether you’re a single project team fine-tuning models or a production-scale team managing thousand-GPU workloads. We’ll break down where providers fit, what actually drives performance, how pricing really works, and how to design a control plane that makes multi-cloud not just possible, but a competitive advantage. + + + +## A quick map of the market + +Two forces define the market: **Target scale** (from single nodes → racks → multi-rack pods) and **automation maturity** (manual VMs → basic Kubernetes → API-first orchestration). + + + +These axes split providers into distinct archetypes—each with different economics, fabrics, and operational realities. + +### Categories at a glance + +| Category | Description | Examples | +| :---- | :---- | :---- | +| **Classical hyperscalers** | General-purpose clouds with GPU SKUs bolted on | AWS, Google Cloud, Azure, OCI | +| **Massive neoclouds** | GPU-first operators built around dense HGX or MI-series clusters | CoreWeave, Lambda, Nebius, Crusoe | +| **Rapidly-catching neoclouds** | Smaller GPU-first players building out aggressively | RunPod, DataCrunch, Voltage Park, TensorWave, Hot Aisle | +| **Cloud marketplaces** | Don’t own capacity; sell orchestration + unified API over multiple backends | NVIDIA DGX Cloud (Lepton), Modal, Lightning AI, dstack Sky | +| **DC aggregators** | Aggregate idle capacity from third-party datacenters, pricing via market dynamics | Vast.ai | + +> Massive neoclouds lead at extreme GPU scales. Hyperscalers may procure GPU capacity from these GPU-first operators for both training and inference. + +## Silicon reality check + +=== "NVIDIA" + **NVIDIA** remains the path of least resistance for most teams—CUDA and the NVIDIA Container Toolkit still lead in framework compatibility and tooling maturity. H100 is now table stakes and widely available across clouds, a reflection of billions in GPU capex flowing into the open market. GB200 takes it further with tightly coupled domains ideal for memory- and bandwidth-heavy prefill, while cheaper pools can handle lighter decode phases. + +=== "AMD" + **AMD** has now crossed the viability threshold with ROCm 6/7—native PyTorch wheels, ROCm containers, and upstream support in vLLM/SGLang mean OSS stacks “Day 0” if you standardize ROCm images. MI300X (192 GB) and MI350X (288 GB HBM3E) match or exceed NVIDIA on per-GPU memory and are increasingly listed by neoclouds. The new MI355X further pushes boundaries—designed for rack-scale AI, it packs massive HBM3E pools in high-density systems for ultra-large model throughput. + +=== "TPU & Trainium" + **TPUs** and **Trainium** excel in tightly coupled training when you’re all-in on one provider, letting you amortize integration over years. The trade-offs—vendor lock-in, slower OSS support, and smaller ecosystems—make them viable mainly for multi-year, hyperscale workloads where efficiency outweighs migration cost. + +> **AMD** vs **NVIDIA** fit. MI300X matches H200 in capacity (192 GB vs 141 GB) but with more headroom for long-context prefill. MI325X (256 GB) is rolling out slowly, with many providers jumping to MI350X/MI355X (288 GB HBM3E). These top models exceed B200’s 192 GB, making them viable drop-ins where ROCm is ready; GB200/NVL still lead for ultra-low-latency collectives. + +## What you’re really buying + +The GPU SKU is only one piece. Real throughput depends on the system around it. Clusters are optional—until your workload forces them. + +| Dimension | Why it matters | Examples | +| :---- | :---- | :---- | +| **GPU memory** | Governs max batch size and KV-cache headroom, reducing parallelism overhead. | H100 (80 GB), H200 (~141 GB), B200 (~192 GB), MI300X (192 GB), MI325X (256 GB), MI350X/MI355X (288 GB). | +| **Fabric bandwidth** | Dictates all-reduce speed and MoE routing efficiency. Matters beyond a few nodes | 400 Gb/s – 3.2 Tb/s (e.g., 8×400 Gb/s NICs) | +| **Topology** | Low-diameter, uniform interconnect pods beat ad-hoc multi-rack for scale efficiency | HGX islands | +| **Local NVMe** | NVMe hides object-store latency for shards and checkpoints | Multi-TB local SSD per node is common on training SKUs | +| **Network volumes** | Removes “copy to every node” overhead | FSx for Lustre, Filestore, managed NFS; in HPC/neocloud setups, Vast and Weka are common. | +| **Orchestration** | Containers, placement, gang scheduling, autoscaling | K8s+Kueue, KubeRay, dstack, SLURM, vendor schedulers | + +## Pricing models – and what they hide + +Price tables don’t show availability risk. Commitments lower cost and increase odds you get the hardware when you need it. + +| With commitments | No committments | +| ----- | ----- | +| **Long-term (1–3 years)** Reserved or savings plans. 30–70% below on-demand. High capacity assurance, but utilization risk if needs shift. | **On-demand** Launch instantly—if quota allows. Highest $/hr. Limited availability for hot SKUs. | +| **Short-term (6–12 months)** Private offers, common with neoclouds. 20–60% off. Often includes hard capacity guarantees. | **Flex / queued** Starts when supply frees up. Cheaper than on-demand; runs capped in duration. | +| **Calendar capacity** Fixed-date bookings (AWS Capacity Blocks, GCP Calendar). Guarantees start time for planned runs. | **Spot / preemptible** 60–90% off. Eviction-prone; needs checkpointing/stateless design. | + +!!! info "Playbook" + Lock in calendar or reserved for steady base load or planned long runs. Keep urgent, interactive, and development/CI/CD work on on-demand. Push experiments and ephemeral runs to spot/flex. Always leave exit ramps to pivot to new SKUs. + +### Quotas, approvals, and the human factor + +Even listed SKUs may be gated. Hyperscalers and neoclouds enforce quotas and manual approvals—region by region—especially for new accounts on credits. If you can’t clear those gates, multi-cloud isn’t optional, it’s survival. + +### H100 pricing example + +Below is the price range for a single H100 SXM across providers. + + + +> Price is per GPU and excludes full CPU, disk amount and type, and network factors. 8xGPU multi-node setups with fast interconnects will cost more. + +For comparison, below is the price range for H100×GPU clusters across providers. + + + +> Most hyperscalers and neoclouds need short- or long-term contracts, though providers like RunPod, DataCrunch, and Nebius offer on-demand clusters. Larger capacity and longer commitments bring bigger discounts — Nebius offers up to 35% off for longer terms. + +## New GPU generations – why they matter + +* **Memory and bandwidth scaling.** Higher HBM and faster interconnects expand batch size, context length, and per-node throughput. NVIDIA’s B300 and AMD’s MI355X push this further with massive HBM3E capacity and rack-scale fabrics, targeting ultra-large training runs. +* **Fabrics.** Each new generation often brings major interconnect upgrades — GB200 with NVLink5 (1.8 TB/s) and 800 Gb/s Infiniband, MI355X with PCIe Gen6 and NDR. These cut all-reduce and MoE latency, but only if the cloud deploys matching network infrastructure. Pairing new GPUs with legacy 400 Gb/s links can erase much of the gain. +* **Prefill vs decode.** Prefill (memory/bandwidth heavy) thrives on large HBM and tightly coupled GPUs like GB200 NVL72. Decode can run cheaper, on high-concurrency pools. Splitting them is a major cost lever. +* **Cascade.** Top-end SKUs arrive roughly every 18–24 months, with mid-cycle refreshes in between. Each launch pushes older SKUs down the price curve — locking in for years right before a release risks overpaying within months. + +!!! info "Prices" + H100 prices have dropped significantly in recent years due to new GPU generations and models like DeepSeek that require more memory. New generations include the H200 and B200. Only AWS has reduced H100 instance prices by 44%. H200 and later B200 prices are expected to follow the same trend. + + **AMD** MI300X pricing is also softening as MI350X/MI355X roll out, with some neoclouds undercutting H100/H200 on $/GPU-hr while offering more memory per GPU. + + +## Where provisioning is going + +The shift is from ad-hoc starts to time-bound allocations. + +Large runs are booked ahead; daily work rides elastic pools. Placement engines increasingly decide on region + provider + interconnect before SKU. The mindset moves from “more GPUs” to “higher sustained utilization.” + +## Control plane as the force multiplier + +A real multi-cloud control plane should: + +* **Be quota-aware and cost-aware** – place jobs where they’ll start fastest at the best $/SLO. +* **Maximize utilization** – keep GPUs busy with checkpointing, resumable pipelines, and efficient gang scheduling. +* **Enforce portability** – one container spec, CUDA+ROCm images, upstream framework compatibility, state in object storage. + +This turns capacity from individual silos into one fungible pool. + +## Final takeaways + +* **Price ≠ cost** — List price often explains <50% of total job cost on multi-node training; fabric and storage dominate at scale. +* **Match commitments to workload reality** — and leave room for next-gen hardware. +* **Multi-cloud isn’t backup, it’s strategy** – keep a warm secondary. +* **Watch AMD’s ramp-up** – the MI series is becoming production-ready, and MI355X availability is set to expand quickly as providers bring it online. +* **Control plane is leverage** – define once, run anywhere, at the cheapest viable pool. + +??? info "Scope & limitations of this report" + + - **Provider coverage.** The vendor set is a curated sample aligned with the dstack team’s view of the market. A limited group of community members and domain experts reviewed drafts. Corrections, reproducibility notes, and additional data points are welcome. + - **Methodology gaps.** We did not perform cross-vendor **price normalization** (CPU/RAM/NVMe/fabric adjustments, region effects, egress), controlled **microbenchmarks** (NCCL/all-reduce, MoE routing latency, KV-cache behavior, object store vs. parallel FS), or a full **orchestration capability matrix** (scheduler semantics, gang scheduling, quota APIs, preemption, multi-tenancy). + - **Next steps.** We plan to publish price normalization, hardware/network microbenchmarks, and a scheduler capability matrix; preliminary harnesses are linked in the appendix. Contributors welcome. + + +> If you need a lighter, simpler orchestration and control-plane alternative to Kubernetes or Slurm, consider [dstack :material-arrow-top-right-thin:{ .external }](https://github.com/dstackai/dstack/){:target="_blank"}. +It’s open-source and self-hosted. + +??? info "dstack Sky" + If you want unified access to low-cost on-demand and spot GPUs across multiple clouds, try [dstack Sky :material-arrow-top-right-thin:{ .external }](https://sky.dstack.ai/){:target="_blank"}. + + + + You can use it with your own cloud accounts or through the cloud marketplace. diff --git a/docs/docs/guides/protips.md b/docs/docs/guides/protips.md index f51cc4777..cfb01546d 100644 --- a/docs/docs/guides/protips.md +++ b/docs/docs/guides/protips.md @@ -321,6 +321,33 @@ retry: +## Profiles + +Sometimes, you may want to reuse parameters across runs or set defaults so you don’t have to repeat them in every configuration. You can do this by defining a profile. + +??? info ".dstack/profiles.yml" + A profile file can be created either globally in `~/.dstack/profiles.yml` or locally in `.dstack/profiles.yml`: + + ```yaml + profiles: + - name: my-profile + # If set to true, this profile will be applied automatically + default: true + + # The spot pololicy can be "spot", "on-demand", or "auto" + spot_policy: auto + # Limit the maximum price of the instance per hour + max_price: 1.5 + # Stop any run if it runs longer that this duration + max_duration: 1d + # Use only these backends + backends: [azure, lambda] + ``` + + Check [`.dstack/profiles.yml`](../reference/profiles.yml.md) to see what properties can be defined there. + +A profile can be set as `default` to apply automatically to any run, or specified with `--profile NAME` in `dstack apply`. + ## Projects If you're using multiple `dstack` projects (e.g., from different `dstack` servers), diff --git a/docs/docs/reference/profiles.yml.md b/docs/docs/reference/profiles.yml.md index c245f245c..c97f9d427 100644 --- a/docs/docs/reference/profiles.yml.md +++ b/docs/docs/reference/profiles.yml.md @@ -1,42 +1,32 @@ -# profiles.yml +# .dstack/profiles.yml -Sometimes, you may want to reuse the same parameters across different [`.dstack.yml`](dstack.yml.md) configurations. +Sometimes, you may want to reuse the same parameters across runs or set your own defaults so you don’t have to repeat them in every run configuration. You can do this by defining a profile, either globally in `~/.dstack/profiles.yml` or locally in `.dstack/profiles.yml`. -This can be achieved by defining those parameters in a profile. +A profile can be set as `default` to apply automatically to any run, or specified with `--profile NAME` in `dstack apply`. -Profiles can be defined on the repository level (via the `.dstack/profiles.yml` file in the root directory of the -repository) or on the global level (via the `~/.dstack/profiles.yml` file). - -Any profile can be marked as default so that it will be applied automatically for any run. Otherwise, you can refer to a specific profile -via `--profile NAME` in `dstack apply`. - -### Example +Example:
```yaml profiles: - name: my-profile + # If set to true, this profile will be applied automatically + default: true # The spot pololicy can be "spot", "on-demand", or "auto" spot_policy: auto - # Limit the maximum price of the instance per hour max_price: 1.5 - # Stop any run if it runs longer that this duration max_duration: 1d - # Use only these backends backends: [azure, lambda] - - # If set to true, this profile will be applied automatically - default: true ```
-The profile configuration supports many properties. See below. +The profile configuration supports most properties that a run configuration supports — see below. ### Root reference @@ -51,3 +41,9 @@ The profile configuration supports many properties. See below. #SCHEMA# dstack._internal.core.models.profiles.ProfileRetry overrides: show_root_heading: false + +### `utilization_policy` + +#SCHEMA# dstack._internal.core.models.profiles.UtilizationPolicy + overrides: + show_root_heading: false diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0317cc74f..5eb8e6110 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,13 +9,15 @@ "version": "2.0.0", "license": "Apache 2.0", "dependencies": { - "@cloudscape-design/chat-components": "^1.0.19", - "@cloudscape-design/collection-hooks": "^1.0.56", - "@cloudscape-design/components": "^3.0.856", - "@cloudscape-design/design-tokens": "^3.0.51", - "@cloudscape-design/global-styles": "^1.0.33", + "@cloudscape-design/chat-components": "^1.0.62", + "@cloudscape-design/collection-hooks": "^1.0.74", + "@cloudscape-design/component-toolkit": "^1.0.0-beta.120", + "@cloudscape-design/components": "^3.0.1091", + "@cloudscape-design/design-tokens": "^3.0.60", + "@cloudscape-design/global-styles": "^1.0.45", "@hookform/resolvers": "^2.9.10", "@reduxjs/toolkit": "^1.9.1", + "@types/yup": "^0.29.14", "ace-builds": "^1.36.3", "classnames": "^2.5.1", "css-minimizer-webpack-plugin": "^4.2.2", @@ -23,7 +25,7 @@ "i18next": "^24.0.2", "lodash": "^4.17.21", "openai": "^4.33.1", - "prismjs": "^1.29.0", + "prismjs": "^1.30.0", "rc-tooltip": "^5.2.2", "react": "^18.3.1", "react-avatar": "^5.0.3", @@ -2069,9 +2071,10 @@ } }, "node_modules/@cloudscape-design/chat-components": { - "version": "1.0.19", - "resolved": "https://registry.npmjs.org/@cloudscape-design/chat-components/-/chat-components-1.0.19.tgz", - "integrity": "sha512-0fjmOQ1Pnw6YW+xVF9ULgTd4bxPZN0tMonHB1yLZ6uq7gMvdHYnvn4DfZUSHBGAgMQgza5bAnNqoDo7eoODUxw==", + "version": "1.0.62", + "resolved": "https://registry.npmjs.org/@cloudscape-design/chat-components/-/chat-components-1.0.62.tgz", + "integrity": "sha512-8Tqc5JqLmSMQe2nG0q1I7Q8m08kfuLJCFhTqgfYZDKu9g/HVP8d8Q42FPCcHesGWq1xM+S1wSioxJ5uhdzWE8A==", + "license": "Apache-2.0", "dependencies": { "@cloudscape-design/component-toolkit": "^1.0.0-beta", "@cloudscape-design/test-utils-core": "^1.0.0", @@ -2079,32 +2082,33 @@ }, "peerDependencies": { "@cloudscape-design/components": "^3", - "@cloudscape-design/design-tokens": "^3", - "react": "^18.2.0", - "react-dom": "^18.2.0" + "react": ">=18.2.0" } }, "node_modules/@cloudscape-design/collection-hooks": { - "version": "1.0.56", - "resolved": "https://registry.npmjs.org/@cloudscape-design/collection-hooks/-/collection-hooks-1.0.56.tgz", - "integrity": "sha512-1nDayJZTXMwb/MDcPzmfr12t423V+leKQI+apA0rb5j19SJhqz9AMUYF9QWBGmHsTV2FlKZI6yghbZBkVWDL6Q==", + "version": "1.0.74", + "resolved": "https://registry.npmjs.org/@cloudscape-design/collection-hooks/-/collection-hooks-1.0.74.tgz", + "integrity": "sha512-yAcD7vjFqbwqMCamUcKRXp403u8RcmC9izyPEYiWod9elt7x0GT1ypPyo9ZRyQuFrBsv2nwubBUrChcYaWooZw==", + "license": "Apache-2.0", "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + "react": ">=16.8.0" } }, "node_modules/@cloudscape-design/component-toolkit": { - "version": "1.0.0-beta.79", - "resolved": "https://registry.npmjs.org/@cloudscape-design/component-toolkit/-/component-toolkit-1.0.0-beta.79.tgz", - "integrity": "sha512-gNc71f/tFW83vjGM11w5YO1LiyW6M1U/vRAYMqbbq71EFIw+JeJDwYBddiy2d/jkBHkCtJt/RL2TVH5YqPOMow==", + "version": "1.0.0-beta.120", + "resolved": "https://registry.npmjs.org/@cloudscape-design/component-toolkit/-/component-toolkit-1.0.0-beta.120.tgz", + "integrity": "sha512-QQfquFjubZvDpJ+Tlt3UHI3KWGvMhwoksY6tG7E41qOrS9y+YbDJuJyiqaCbm5S2PzZ33JBL0bWsXrJesZu6tA==", + "license": "Apache-2.0", "dependencies": { "@juggle/resize-observer": "^3.3.1", "tslib": "^2.3.1" } }, "node_modules/@cloudscape-design/components": { - "version": "3.0.856", - "resolved": "https://registry.npmjs.org/@cloudscape-design/components/-/components-3.0.856.tgz", - "integrity": "sha512-e0dK7mibvvdsJOppuCCe29XJzsYcTCDqiuf8bXCafDgaomsgTahPA8TXj3aQyC3rZwEjuLbFj0Uil407mfALIw==", + "version": "3.0.1091", + "resolved": "https://registry.npmjs.org/@cloudscape-design/components/-/components-3.0.1091.tgz", + "integrity": "sha512-ESV83m/laX9OkuITjeucYRBi4WQSu9w8yniRZjRapiTH+zTlBxQv8Gcnvr9UYPo3cbYyig2HIdbAlOagDplgfA==", + "license": "Apache-2.0", "dependencies": { "@cloudscape-design/collection-hooks": "^1.0.0", "@cloudscape-design/component-toolkit": "^1.0.0-beta", @@ -2113,7 +2117,6 @@ "@dnd-kit/core": "^6.0.8", "@dnd-kit/sortable": "^7.0.2", "@dnd-kit/utilities": "^3.2.1", - "@juggle/resize-observer": "^3.3.1", "ace-builds": "^1.34.0", "balanced-match": "^1.0.2", "clsx": "^1.1.0", @@ -2121,25 +2124,26 @@ "date-fns": "^2.25.0", "intl-messageformat": "^10.3.1", "mnth": "^2.0.0", - "react-keyed-flatten-children": "^1.3.0", + "react-keyed-flatten-children": "^2.2.1", "react-transition-group": "^4.4.2", "tslib": "^2.4.0", "weekstart": "^1.1.0" }, "peerDependencies": { - "react": "^16.8 || ^17 || ^18", - "react-dom": "^16.8 || ^17 || ^18" + "react": ">=16.8.0" } }, "node_modules/@cloudscape-design/design-tokens": { - "version": "3.0.51", - "resolved": "https://registry.npmjs.org/@cloudscape-design/design-tokens/-/design-tokens-3.0.51.tgz", - "integrity": "sha512-s+qNFxw/FfdMCky86nz6xSIQV4UugRkNQYcT3h/EJAak3PUL37nE3tiPnV5OqOU6ZWfg4dcQMdQ+P1ohBnw9eQ==" + "version": "3.0.60", + "resolved": "https://registry.npmjs.org/@cloudscape-design/design-tokens/-/design-tokens-3.0.60.tgz", + "integrity": "sha512-ybj8FfjdhuHZflVDA//ooHJdwc+vny9MESvB95AJpVDhf6PXoaOpWAObn4hkMC770Wk/YwXtKXbx7rjJJQr6ZA==", + "license": "Apache-2.0" }, "node_modules/@cloudscape-design/global-styles": { - "version": "1.0.33", - "resolved": "https://registry.npmjs.org/@cloudscape-design/global-styles/-/global-styles-1.0.33.tgz", - "integrity": "sha512-6bg18XIxkRS2ojMNGxVA8mV35rqkiHDXwOJjfHhYPzg6LjFagZWyg/hRRGuP5MExszB748m2HYYdXT0EejxiPA==" + "version": "1.0.45", + "resolved": "https://registry.npmjs.org/@cloudscape-design/global-styles/-/global-styles-1.0.45.tgz", + "integrity": "sha512-fSrbVpK9W+bg8tmUYqU9Wh2JGciUCGEByVUQDbgMY6feXtYEUKRP2MBL6kEHvoJB7lssZbHdh5/gYaiyxg+P5w==", + "license": "Apache-2.0" }, "node_modules/@cloudscape-design/test-utils-core": { "version": "1.0.44", @@ -5204,6 +5208,12 @@ "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==" }, + "node_modules/@types/yup": { + "version": "0.29.14", + "resolved": "https://registry.npmjs.org/@types/yup/-/yup-0.29.14.tgz", + "integrity": "sha512-Ynb/CjHhE/Xp/4bhHmQC4U1Ox+I2OpfRYF3dnNgQqn1cHa6LK3H1wJMNPT02tSVZA6FYuXE2ITORfbnb6zBCSA==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.33.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.33.1.tgz", @@ -20349,9 +20359,10 @@ "peer": true }, "node_modules/prismjs": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.29.0.tgz", - "integrity": "sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==", + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", "engines": { "node": ">=6" } @@ -20779,21 +20790,17 @@ "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" }, "node_modules/react-keyed-flatten-children": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/react-keyed-flatten-children/-/react-keyed-flatten-children-1.3.0.tgz", - "integrity": "sha512-qB7A6n+NHU0x88qTZGAJw6dsqwI941jcRPBB640c/CyWqjPQQ+YUmXOuzPziuHb7iqplM3xksWAbGYwkQT0tXA==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/react-keyed-flatten-children/-/react-keyed-flatten-children-2.2.1.tgz", + "integrity": "sha512-6yBLVO6suN8c/OcJk1mzIrUHdeEzf5rtRVBhxEXAHO49D7SlJ70cG4xrSJrBIAG7MMeQ+H/T151mM2dRDNnFaA==", + "license": "MIT", "dependencies": { - "react-is": "^16.8.6" + "react-is": "^18.2.0" }, "peerDependencies": { "react": ">=15.0.0" } }, - "node_modules/react-keyed-flatten-children/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" - }, "node_modules/react-redux": { "version": "8.1.3", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.1.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index f3b706924..fa5c511ca 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -96,13 +96,15 @@ "webpack-nano": "^1.1.1" }, "dependencies": { - "@cloudscape-design/chat-components": "^1.0.19", - "@cloudscape-design/collection-hooks": "^1.0.56", - "@cloudscape-design/components": "^3.0.856", - "@cloudscape-design/design-tokens": "^3.0.51", - "@cloudscape-design/global-styles": "^1.0.33", + "@cloudscape-design/chat-components": "^1.0.62", + "@cloudscape-design/collection-hooks": "^1.0.74", + "@cloudscape-design/component-toolkit": "^1.0.0-beta.120", + "@cloudscape-design/components": "^3.0.1091", + "@cloudscape-design/design-tokens": "^3.0.60", + "@cloudscape-design/global-styles": "^1.0.45", "@hookform/resolvers": "^2.9.10", "@reduxjs/toolkit": "^1.9.1", + "@types/yup": "^0.29.14", "ace-builds": "^1.36.3", "classnames": "^2.5.1", "css-minimizer-webpack-plugin": "^4.2.2", @@ -110,7 +112,7 @@ "i18next": "^24.0.2", "lodash": "^4.17.21", "openai": "^4.33.1", - "prismjs": "^1.29.0", + "prismjs": "^1.30.0", "rc-tooltip": "^5.2.2", "react": "^18.3.1", "react-avatar": "^5.0.3", diff --git a/frontend/src/App/Login/LoginByGithubCallback/index.tsx b/frontend/src/App/Login/LoginByGithubCallback/index.tsx index 9f99c7c4b..814846631 100644 --- a/frontend/src/App/Login/LoginByGithubCallback/index.tsx +++ b/frontend/src/App/Login/LoginByGithubCallback/index.tsx @@ -8,6 +8,7 @@ import { UnauthorizedLayout } from 'layouts/UnauthorizedLayout'; import { useAppDispatch } from 'hooks'; import { ROUTES } from 'routes'; import { useGithubCallbackMutation } from 'services/auth'; +import { useLazyGetProjectsQuery } from 'services/project'; import { AuthErrorMessage } from 'App/AuthErrorMessage'; import { Loading } from 'App/Loading'; @@ -22,13 +23,24 @@ export const LoginByGithubCallback: React.FC = () => { const dispatch = useAppDispatch(); const [githubCallback] = useGithubCallbackMutation(); + const [getProjects] = useLazyGetProjectsQuery(); const checkCode = () => { if (code) { githubCallback({ code }) .unwrap() - .then(({ creds: { token } }) => { + .then(async ({ creds: { token } }) => { dispatch(setAuthData({ token })); + + if (process.env.UI_VERSION === 'sky') { + const result = await getProjects().unwrap(); + + if (result?.length === 0) { + navigate(ROUTES.PROJECT.ADD); + return; + } + } + navigate('/'); }) .catch(() => { diff --git a/frontend/src/App/slice.ts b/frontend/src/App/slice.ts index dc53eef41..9d684d585 100644 --- a/frontend/src/App/slice.ts +++ b/frontend/src/App/slice.ts @@ -61,6 +61,7 @@ const getInitialState = (): IAppState => { }, tutorialPanel: { + createProjectCompleted: false, billingCompleted: false, configureCLICompleted: false, discordCompleted: false, diff --git a/frontend/src/App/types.ts b/frontend/src/App/types.ts index 6b6f87a4d..262c1b156 100644 --- a/frontend/src/App/types.ts +++ b/frontend/src/App/types.ts @@ -32,6 +32,7 @@ export interface IAppState { }; tutorialPanel: { + createProjectCompleted: boolean; billingCompleted: boolean; configureCLICompleted: boolean; discordCompleted: boolean; diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 661f72ef5..c1e453c11 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -58,6 +58,7 @@ export const API = { BASE: () => `${API.BASE()}/projects`, LIST: () => `${API.PROJECTS.BASE()}/list`, CREATE: () => `${API.PROJECTS.BASE()}/create`, + CREATE_WIZARD: () => `${API.PROJECTS.BASE()}/create_wizard`, DELETE: () => `${API.PROJECTS.BASE()}/delete`, DETAILS: (name: IProject['project_name']) => `${API.PROJECTS.BASE()}/${name}`, DETAILS_INFO: (name: IProject['project_name']) => `${API.PROJECTS.DETAILS(name)}/get`, @@ -112,6 +113,7 @@ export const API = { BACKENDS: { BASE: () => `${API.BASE()}/backends`, LIST_TYPES: () => `${API.BACKENDS.BASE()}/list_types`, + LIST_BASE_TYPES: () => `${API.BACKENDS.BASE()}/list_base_types`, CONFIG_VALUES: () => `${API.BACKENDS.BASE()}/config_values`, }, diff --git a/frontend/src/components/form/Cards/index.tsx b/frontend/src/components/form/Cards/index.tsx new file mode 100644 index 000000000..17b12f193 --- /dev/null +++ b/frontend/src/components/form/Cards/index.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { Controller, FieldValues } from 'react-hook-form'; +import Cards from '@cloudscape-design/components/cards'; +import { CardsProps } from '@cloudscape-design/components/cards'; + +import { FormCardsProps } from './types'; + +export const FormCards = ({ + name, + control, + onSelectionChange: onSelectionChangeProp, + ...props +}: FormCardsProps) => { + return ( + { + const onSelectionChange: CardsProps['onSelectionChange'] = (event) => { + onChange(event.detail.selectedItems.map(({ value }) => value)); + onSelectionChangeProp?.(event); + }; + + const selectedItems = props.items.filter((item) => fieldRest.value?.includes(item.value)); + + return ( + + ); + }} + /> + ); +}; diff --git a/frontend/src/components/form/Cards/types.ts b/frontend/src/components/form/Cards/types.ts new file mode 100644 index 000000000..857ac77a5 --- /dev/null +++ b/frontend/src/components/form/Cards/types.ts @@ -0,0 +1,7 @@ +import { Control, FieldValues, Path } from 'react-hook-form'; +import { CardsProps } from '@cloudscape-design/components/cards'; + +export type FormCardsProps = CardsProps & { + control: Control; + name: Path; +}; diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index e24d6bd3e..f69a5589f 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -13,6 +13,7 @@ export { default as SpaceBetween } from '@cloudscape-design/components/space-bet export { default as Container } from '@cloudscape-design/components/container'; export { default as Spinner } from '@cloudscape-design/components/spinner'; export { default as Cards } from '@cloudscape-design/components/cards'; +export type { CardsProps } from '@cloudscape-design/components/cards'; export { default as Header } from '@cloudscape-design/components/header'; export { default as Link } from '@cloudscape-design/components/link'; export type { LinkProps } from '@cloudscape-design/components/link'; @@ -32,6 +33,8 @@ export { default as CheckboxCSD } from '@cloudscape-design/components/checkbox'; export { default as InputCSD } from '@cloudscape-design/components/input'; export { default as SelectCSD } from '@cloudscape-design/components/select'; export type { SelectProps as SelectCSDProps } from '@cloudscape-design/components/select'; +export { default as MultiselectCSD } from '@cloudscape-design/components/multiselect'; +export type { MultiselectProps } from '@cloudscape-design/components/multiselect'; export { default as StatusIndicator } from '@cloudscape-design/components/status-indicator'; export type { StatusIndicatorProps } from '@cloudscape-design/components/status-indicator'; export { default as Popover } from '@cloudscape-design/components/popover'; @@ -58,7 +61,9 @@ export type { LineChartProps } from '@cloudscape-design/components/line-chart/in export type { ModalProps } from '@cloudscape-design/components/modal'; export { default as AnchorNavigation } from '@cloudscape-design/components/anchor-navigation'; export { default as ExpandableSection } from '@cloudscape-design/components/expandable-section'; +export { default as KeyValuePairs } from '@cloudscape-design/components/key-value-pairs'; export { I18nProvider } from '@cloudscape-design/components/i18n'; +export { default as Wizard } from '@cloudscape-design/components/wizard'; // custom components export { NavigateLink } from './NavigateLink'; @@ -77,6 +82,8 @@ export type { FormMultiselectOptions, FormMultiselectProps } from './form/Multis export { FormS3BucketSelector } from './form/S3BucketSelector'; export type { FormTilesProps } from './form/Tiles/types'; export { FormTiles } from './form/Tiles'; +export type { FormCardsProps } from './form/Cards/types'; +export { FormCards } from './form/Cards'; export { Notifications } from './Notifications'; export { ConfirmationDialog } from './ConfirmationDialog'; export { FileUploader } from './FileUploader'; diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index f2ec64205..8820bd30d 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -19,7 +19,8 @@ const container = document.getElementById('root'); const theme: Theme = { tokens: { - fontFamilyBase: 'metro-web, Metro, -apple-system, "system-ui", "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif', + fontFamilyBase: + 'metro-web, Metro, -apple-system, "system-ui", "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif', fontSizeHeadingS: '15px', fontSizeHeadingL: '19px', fontSizeHeadingXl: '22px', diff --git a/frontend/src/layouts/AppLayout/TutorialPanel/constants.tsx b/frontend/src/layouts/AppLayout/TutorialPanel/constants.tsx index 3a418a6d6..5292e24d6 100644 --- a/frontend/src/layouts/AppLayout/TutorialPanel/constants.tsx +++ b/frontend/src/layouts/AppLayout/TutorialPanel/constants.tsx @@ -44,6 +44,7 @@ export enum HotspotIds { ADD_TOP_UP_BALANCE = 'billing-top-up-balance', PAYMENT_CONTINUE_BUTTON = 'billing-payment-continue-button', CONFIGURE_CLI_COMMAND = 'configure-cli-command', + CREATE_FIRST_PROJECT = 'create-first-project', } export const BILLING_TUTORIAL: TutorialPanelProps.Tutorial = { @@ -52,7 +53,7 @@ export const BILLING_TUTORIAL: TutorialPanelProps.Tutorial = { description: ( <> - Top up your balance via a credit card to use GPU by dstack Sky. + If you plan to use the GPU marketplace, top up your balance with a credit card. ), @@ -101,6 +102,31 @@ export const CONFIGURE_CLI_TUTORIAL: TutorialPanelProps.Tutorial = { ], }; +export const CREATE_FIRST_PROJECT: TutorialPanelProps.Tutorial = { + completed: false, + title: 'Create a project', + description: ( + <> + + Create your first project. Choose to use the GPU marketplace or configure your own cloud credentials. + + + ), + completedScreenDescription: 'TBA', + tasks: [ + { + title: 'Create the first project', + steps: [ + { + title: 'Create the first project', + content: 'Create the first project', + hotspotId: HotspotIds.CREATE_FIRST_PROJECT, + }, + ], + }, + ], +}; + export const JOIN_DISCORD_TUTORIAL: TutorialPanelProps.Tutorial = { completed: false, title: 'Community', diff --git a/frontend/src/layouts/AppLayout/TutorialPanel/hooks.ts b/frontend/src/layouts/AppLayout/TutorialPanel/hooks.ts index 25ee53220..f27575f26 100644 --- a/frontend/src/layouts/AppLayout/TutorialPanel/hooks.ts +++ b/frontend/src/layouts/AppLayout/TutorialPanel/hooks.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; import { DISCORD_URL, @@ -8,6 +8,8 @@ import { } from 'consts'; import { useAppDispatch, useAppSelector } from 'hooks'; import { goToUrl } from 'libs'; +import { ROUTES } from 'routes'; +import { useGetProjectsQuery } from 'services/project'; import { useGetRunsQuery } from 'services/run'; import { useGetUserBillingInfoQuery } from 'services/user'; @@ -17,6 +19,7 @@ import { useSideNavigation } from '../hooks'; import { BILLING_TUTORIAL, CONFIGURE_CLI_TUTORIAL, + CREATE_FIRST_PROJECT, // CREDITS_TUTORIAL, JOIN_DISCORD_TUTORIAL, QUICKSTART_TUTORIAL, @@ -26,13 +29,22 @@ import { ITutorialItem } from 'App/types'; export const useTutorials = () => { const navigate = useNavigate(); + const location = useLocation(); const dispatch = useAppDispatch(); const { billingUrl } = useSideNavigation(); const useName = useAppSelector(selectUserName); - const { billingCompleted, configureCLICompleted, discordCompleted, tallyCompleted, quickStartCompleted, hideStartUp } = - useAppSelector(selectTutorialPanel); + const { + billingCompleted, + createProjectCompleted, + configureCLICompleted, + discordCompleted, + tallyCompleted, + quickStartCompleted, + hideStartUp, + } = useAppSelector(selectTutorialPanel); const { data: userBillingData } = useGetUserBillingInfoQuery({ username: useName ?? '' }, { skip: !useName }); + const { data: projectData } = useGetProjectsQuery(); const { data: runsData } = useGetRunsQuery({ limit: 1, }); @@ -40,18 +52,32 @@ export const useTutorials = () => { const completeIsChecked = useRef(false); useEffect(() => { - if (userBillingData && runsData && !completeIsChecked.current) { + if ( + userBillingData && + projectData && + runsData && + !completeIsChecked.current && + location.pathname !== ROUTES.PROJECT.ADD + ) { const billingCompleted = userBillingData.balance > 0; const configureCLICompleted = runsData.length > 0; + const createProjectCompleted = projectData.length > 0; let tempHideStartUp = hideStartUp; if (hideStartUp === null) { - tempHideStartUp = billingCompleted && configureCLICompleted; + tempHideStartUp = billingCompleted && configureCLICompleted && createProjectCompleted; } // Set hideStartUp without updating localstorage - dispatch(updateTutorialPanelState({ billingCompleted, configureCLICompleted, hideStartUp: tempHideStartUp })); + dispatch( + updateTutorialPanelState({ + billingCompleted, + configureCLICompleted, + createProjectCompleted, + hideStartUp: tempHideStartUp, + }), + ); if (!tempHideStartUp && process.env.UI_VERSION === 'sky') { dispatch(openTutorialPanel()); @@ -59,7 +85,17 @@ export const useTutorials = () => { completeIsChecked.current = true; } - }, [userBillingData, runsData]); + }, [userBillingData, runsData, projectData, location.pathname]); + + useEffect(() => { + if (projectData && projectData.length > 0 && !createProjectCompleted) { + dispatch( + updateTutorialPanelState({ + createProjectCompleted: true, + }), + ); + } + }, [projectData]); const startBillingTutorial = useCallback(() => { navigate(billingUrl); @@ -69,6 +105,14 @@ export const useTutorials = () => { dispatch(updateTutorialPanelState({ billingCompleted: true })); }, []); + const startFirstProjectTutorial = useCallback(() => { + navigate(ROUTES.PROJECT.ADD); + }, []); + + const finishFirstProjectTutorial = useCallback(() => { + dispatch(updateTutorialPanelState({ createProjectCompleted: true })); + }, []); + const startConfigCliTutorial = useCallback(() => {}, [billingUrl]); const finishConfigCliTutorial = useCallback(() => { @@ -103,8 +147,16 @@ export const useTutorials = () => { // }, { - ...CONFIGURE_CLI_TUTORIAL, + ...CREATE_FIRST_PROJECT, id: 2, + completed: createProjectCompleted, + startCallback: startFirstProjectTutorial, + finishCallback: finishFirstProjectTutorial, + }, + + { + ...CONFIGURE_CLI_TUTORIAL, + id: 3, completed: configureCLICompleted, startCallback: startConfigCliTutorial, finishCallback: finishConfigCliTutorial, @@ -112,7 +164,7 @@ export const useTutorials = () => { { ...BILLING_TUTORIAL, - id: 3, + id: 4, completed: billingCompleted, startCallback: startBillingTutorial, finishCallback: finishBillingTutorial, @@ -120,7 +172,7 @@ export const useTutorials = () => { { ...QUICKSTART_TUTORIAL, - id: 4, + id: 5, startWithoutActivation: true, completed: quickStartCompleted, startCallback: startQuickStartTutorial, @@ -128,7 +180,7 @@ export const useTutorials = () => { { ...JOIN_DISCORD_TUTORIAL, - id: 5, + id: 6, startWithoutActivation: true, completed: discordCompleted, startCallback: startDiscordTutorial, @@ -136,11 +188,13 @@ export const useTutorials = () => { ]; }, [ billingUrl, + createProjectCompleted, quickStartCompleted, discordCompleted, tallyCompleted, billingCompleted, configureCLICompleted, + finishFirstProjectTutorial, finishBillingTutorial, finishConfigCliTutorial, ]); diff --git a/frontend/src/libs/filters.ts b/frontend/src/libs/filters.ts index d3f06c98d..7546f8d82 100644 --- a/frontend/src/libs/filters.ts +++ b/frontend/src/libs/filters.ts @@ -101,3 +101,23 @@ export const requestParamsToTokens = ({ tokens, }; }; + +export const requestParamsToArray = ({ + searchParams, + paramName, +}: { + searchParams: URLSearchParams; + paramName: Key; +}) => { + const paramValues: string[] = []; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + + for (const [paramKey, paramValue] of searchParams.entries()) { + if (paramKey === paramName) { + paramValues.push(paramValue); + } + } + + return paramValues; +}; diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 7c40a16bd..de559dbc4 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -10,6 +10,8 @@ "delete": "Delete", "remove": "Remove", "apply": "Apply", + "next": "Next", + "previous": "Back", "settings": "Settings", "match_count_with_value_one": "{{count}} match", "match_count_with_value_other": "{{count}} matches", @@ -69,7 +71,7 @@ "runs": "Runs", "models": "Models", "fleets": "Fleets", - "project": "Project", + "project": "project", "project_other": "Projects", "general": "General", "users": "Users", @@ -187,11 +189,27 @@ "backend": "Backend", "settings": "Settings" }, + "wizard": { + "submit": "Create" + }, "edit": { "general": "General", "project_name": "Project name", "owner": "Owner", "project_name_description": "Only latin characters, dashes, underscores, and digits", + "project_type": "Project type", + "project_type_description": "Choose which project type you want to create", + "backends": "Backends", + "base_backends_description": "dstack will automatically collect offers from the following providers. Deselect providers you don’t want to use.", + "backends_description": "The following backends can be configured with your own cloud credentials in the project settings after the project is created.", + "default_fleet": "Create default fleet", + "default_fleet_description": "You can create default fleet for project", + "fleet_name": "Fleet name", + "fleet_name_description": "Only latin characters, dashes, underscores, and digits", + "fleet_min_instances": "Min number of instances", + "fleet_min_instances_description": "Only digits", + "fleet_max_instances": "Max number of instances", + "fleet_max_instances_description": "Only digits", "is_public": "Make project public", "is_public_description": "Public projects can be accessed by any user without being a member", "backend": "Backend", @@ -206,7 +224,7 @@ "update_visibility_confirm_title": "Change project visibility", "update_visibility_confirm_message": "Are you sure you want to change the project visibility? This will affect who can access this project.", "change_visibility": "Change visibility", - "project_visibility": "Project visibility", + "project_visibility": "Visibility", "project_visibility_description": "Control who can access this project", "make_project_public": "Make project public", "delete_project_confirm_title": "Delete project", @@ -364,7 +382,7 @@ "quickstart_message_text": "Check out the quickstart guide to get started with dstack", "nomatch_message_title": "No matches", "nomatch_message_text": "We can't find a match. Try to change project or clear filter", - "filter_property_placeholder": "Filter runs by properties", + "filter_property_placeholder": "Filter by properties", "project": "Project", "project_placeholder": "Filtering by project", "repo": "Repository", @@ -449,11 +467,11 @@ }, "offer": { "title": "Offers", - "filter_property_placeholder": "Filter offers by properties", + "filter_property_placeholder": "Filter by properties", "backend": "Backend", "backend_plural": "Backends", "availability": "Availability", - "groupBy": "Group by", + "groupBy": "Group by properties", "region": "Region", "count": "Count", "price": "$/GPU", @@ -461,6 +479,8 @@ "spot": "Spot policy", "empty_message_title_select_project": "Select a project", "empty_message_text_select_project": "Use the filter above to select a project", + "empty_message_title_select_groupBy": "Select a group by", + "empty_message_text_select_groupBy": "Use the field above to select a group by", "empty_message_title": "No offers", "empty_message_text": "No offers to display.", "nomatch_message_title": "No matches", @@ -509,7 +529,7 @@ "nomatch_message_text": "We can't find a match.", "nomatch_message_button_label": "Clear filter", "active_only": "Active fleets", - "filter_property_placeholder": "Filter fleets by properties", + "filter_property_placeholder": "Filter by properties", "statuses": { "active": "Active", "submitted": "Submitted", @@ -519,7 +539,7 @@ }, "instances": { "active_only": "Active instances", - "filter_property_placeholder": "Filter instances by properties", + "filter_property_placeholder": "Filter by properties", "title": "Instances", "empty_message_title": "No instances", "empty_message_text": "No instances to display.", @@ -557,7 +577,7 @@ "delete_volumes_confirm_title": "Delete volumes", "delete_volumes_confirm_message": "Are you sure you want to delete these volumes?", "active_only": "Active volumes", - "filter_property_placeholder": "Filter volumes by properties", + "filter_property_placeholder": "Filter by properties", "name": "Name", "project": "Project name", diff --git a/frontend/src/pages/Offers/List/hooks/useEmptyMessages.tsx b/frontend/src/pages/Offers/List/hooks/useEmptyMessages.tsx index f978b8bc6..1d6ba07ef 100644 --- a/frontend/src/pages/Offers/List/hooks/useEmptyMessages.tsx +++ b/frontend/src/pages/Offers/List/hooks/useEmptyMessages.tsx @@ -7,10 +7,12 @@ export const useEmptyMessages = ({ clearFilter, isDisabledClearFilter, projectNameSelected, + groupBySelected, }: { clearFilter?: () => void; isDisabledClearFilter?: boolean; projectNameSelected?: boolean; + groupBySelected?: boolean; }) => { const { t } = useTranslation(); @@ -24,6 +26,15 @@ export const useEmptyMessages = ({ ); } + if (!groupBySelected) { + return ( + + ); + } + return (