Repository: skypilot-org/skypilot
Pull request #6272 (2025-07-15 21:04:37)

Make sure catalogs get updated periodically on the executor processes
When a new catalog file is fetched, there might be a difference for the catalogs among different executor processes, despite there being a single source of truth, on disk at `~/.sky/catalogs/*`.

TL;DR this is because `read_catalog` returns a `LazyDataFrame`, which gets assigned to a global variable, for each unique (cloud, catalog) pair. And `LazyDataFrame`  caches the DataFrame in memory for the duration of the process' lifetime, because it only calls `update_func` if `self._df` is None, which is only when it's first called.

This PR changes the behaviour of `LazyDataFrame`, such that now, `update_if_stale_func` returns a bool, that indicates if an update was done or not, and this is called on every read (`_load_df`), to ensure that a long running executor process is aware if it is time to refresh the catalog, and save the new one to disk.

To account for the performance penalty of calling the update if stale function for every read, we add two optimizations:
1. In `_update_catalog` of `read_catalog`, we add a fast path by calling `_need_update` before trying to acquire the file lock, to prevent lock contentions. We still need to do it after acquiring the lock, to ensure only one process gets to write to disk, so that part is left as is
2. Add `@annotations.lru_cache(scope='request')` to `_load_df` in `LazyDataFrame`. A single request could read a catalog multiple times, so this helps alleviate unnecessary calls to `update_if_stale_func`

I added a unit test in `test_catalog.py`

Tested (run the relevant ones):

- [x] Code formatting: install pre-commit (auto-check on commit) or `bash format.sh`
- [x] Any manual or new tests for this PR (please specify below)
- [ ] All smoke tests: `/smoke-test` (CI) or `pytest tests/test_smoke.py` (local)
- [x] Relevant individual tests: `/smoke-test -k test_name` (CI) or `pytest tests/test_smoke.py::test_name` (local)
- [ ] Backward compatibility: `/quicktest-core` (CI) or `pytest tests/smoke_tests/test_backward_compat.py` (local)

<!-- CI commands (/-prefixed) can only be triggered by repo members -->



Patch:
diff --git a/examples/distributed-pytorch/train.yaml b/examples/distributed-pytorch/train.yaml
index ab6172af1..db85ccaac 100644
--- a/examples/distributed-pytorch/train.yaml
+++ b/examples/distributed-pytorch/train.yaml
@@ -13,7 +13,7 @@ setup: |
     # SkyPilot's default image on AWS/GCP has CUDA 11.6 (Azure 11.5).
     uv venv --python 3.10
     source .venv/bin/activate
-    uv pip install -r requirements.txt "numpy<2" "torch==1.12.1+cu113" --extra-index-url https://download.pytorch.org/whl/cu113
+    uv pip install -r requirements.txt "numpy<2" "torch==2.7.1+cu118" --extra-index-url https://download.pytorch.org/whl/cu118
 
 run: |
     cd examples
diff --git a/sky/catalog/common.py b/sky/catalog/common.py
index d7d78ea5f..c2d7ba87e 100644
--- a/sky/catalog/common.py
+++ b/sky/catalog/common.py
@@ -13,6 +13,7 @@ from sky import sky_logging
 from sky.adaptors import common as adaptors_common
 from sky.clouds import cloud as cloud_lib
 from sky.skylet import constants
+from sky.utils import annotations
 from sky.utils import common_utils
 from sky.utils import registry
 from sky.utils import rich_utils
@@ -125,17 +126,21 @@ class LazyDataFrame:
 
     We don't need to load the catalog for every SkyPilot call, and this class
     allows us to load the catalog only when needed.
+
+    Use update_if_stale_func to pass in a function that decides whether to
+    update the catalog on disk, updates it if needed, and returns
+    a bool indicating whether the update was done.
     """
 
-    def __init__(self, filename: str, update_func: Callable[[], None]):
+    def __init__(self, filename: str, update_if_stale_func: Callable[[], bool]):
         self._filename = filename
         self._df: Optional['pd.DataFrame'] = None
-        self._update_func = update_func
+        self._update_if_stale_func = update_if_stale_func
 
+    @annotations.lru_cache(scope='request')
     def _load_df(self) -> 'pd.DataFrame':
-        if self._df is None:
+        if self._update_if_stale_func() or self._df is None:
             try:
-                self._update_func()
                 self._df = pd.read_csv(self._filename)
             except Exception as e:  # pylint: disable=broad-except
                 # As users can manually modify the catalog, read_csv can fail.
@@ -193,55 +198,60 @@ def read_catalog(filename: str,
         return last_update + pull_frequency_hours * 3600 < time.time()
 
     def _update_catalog():
+        # Fast path: Exit early to avoid lock contention.
+        if not _need_update():
+            return False
+
         # Atomic check, to avoid conflicts with other processes.
         with filelock.FileLock(meta_path + '.lock'):
-            if _need_update():
-                url = f'{constants.HOSTED_CATALOG_DIR_URL}/{constants.CATALOG_SCHEMA_VERSION}/{filename}'  # pylint: disable=line-too-long
-                url_fallback = f'{constants.HOSTED_CATALOG_DIR_URL_S3_MIRROR}/{constants.CATALOG_SCHEMA_VERSION}/{filename}'  # pylint: disable=line-too-long
-                headers = {'User-Agent': 'SkyPilot/0.7'}
-                update_frequency_str = ''
-                if pull_frequency_hours is not None:
-                    update_frequency_str = (
-                        f' (every {pull_frequency_hours} hours)')
-                with rich_utils.safe_status(
-                        ux_utils.spinner_message(
-                            f'Updating {cloud} catalog: {filename}') +
-                        f'{update_frequency_str}'):
-                    try:
-                        r = requests.get(url=url, headers=headers)
-                        if r.status_code == 429:
-                            # fallback to s3 mirror, github introduced rate
-                            # limit after 2025-05, see
-                            # https://github.com/skypilot-org/skypilot/issues/5438
-                            # for more details
-                            r = requests.get(url=url_fallback, headers=headers)
-                        r.raise_for_status()
-                    except requests.exceptions.RequestException as e:
-                        error_str = (f'Failed to fetch {cloud} catalog '
-                                     f'{filename}. ')
-                        if os.path.exists(catalog_path):
-                            logger.warning(
-                                f'{error_str}Using cached catalog files.')
-                            # Update catalog file modification time.
-                            os.utime(catalog_path, None)  # Sets to current time
-                        else:
-                            logger.error(
-                                f'{error_str}Please check your internet '
-                                'connection.')
-                            with ux_utils.print_exception_no_traceback():
-                                raise e
+            # Double check after acquiring the lock.
+            if not _need_update():
+                return False
+
+            url = f'{constants.HOSTED_CATALOG_DIR_URL}/{constants.CATALOG_SCHEMA_VERSION}/{filename}'  # pylint: disable=line-too-long
+            url_fallback = f'{constants.HOSTED_CATALOG_DIR_URL_S3_MIRROR}/{constants.CATALOG_SCHEMA_VERSION}/{filename}'  # pylint: disable=line-too-long
+            headers = {'User-Agent': 'SkyPilot/0.7'}
+            update_frequency_str = ''
+            if pull_frequency_hours is not None:
+                update_frequency_str = (
+                    f' (every {pull_frequency_hours} hours)')
+            with rich_utils.safe_status(
+                    ux_utils.spinner_message(
+                        f'Updating {cloud} catalog: {filename}') +
+                    f'{update_frequency_str}'):
+                try:
+                    r = requests.get(url=url, headers=headers)
+                    if r.status_code == 429:
+                        # fallback to s3 mirror, github introduced rate
+                        # limit after 2025-05, see
+                        # https://github.com/skypilot-org/skypilot/issues/5438
+                        # for more details
+                        r = requests.get(url=url_fallback, headers=headers)
+                    r.raise_for_status()
+                except requests.exceptions.RequestException as e:
+                    error_str = (f'Failed to fetch {cloud} catalog '
+                                 f'{filename}. ')
+                    if os.path.exists(catalog_path):
+                        logger.warning(
+                            f'{error_str}Using cached catalog files.')
+                        # Update catalog file modification time.
+                        os.utime(catalog_path, None)  # Sets to current time
                     else:
-                        # Download successful, save the catalog to a local file.
-                        os.makedirs(os.path.dirname(catalog_path),
-                                    exist_ok=True)
-                        with open(catalog_path, 'w', encoding='utf-8') as f:
-                            f.write(r.text)
-                        with open(meta_path + '.md5', 'w',
-                                  encoding='utf-8') as f:
-                            f.write(hashlib.md5(r.text.encode()).hexdigest())
-                logger.debug(f'Updated {cloud} catalog {filename}.')
-
-    return LazyDataFrame(catalog_path, update_func=_update_catalog)
+                        logger.error(f'{error_str}Please check your internet '
+                                     'connection.')
+                        with ux_utils.print_exception_no_traceback():
+                            raise e
+                else:
+                    # Download successful, save the catalog to a local file.
+                    os.makedirs(os.path.dirname(catalog_path), exist_ok=True)
+                    with open(catalog_path, 'w', encoding='utf-8') as f:
+                        f.write(r.text)
+                    with open(meta_path + '.md5', 'w', encoding='utf-8') as f:
+                        f.write(hashlib.md5(r.text.encode()).hexdigest())
+            logger.debug(f'Updated {cloud} catalog {filename}.')
+        return True
+
+    return LazyDataFrame(catalog_path, update_if_stale_func=_update_catalog)
 
 
 def _get_instance_type(
diff --git a/sky/catalog/kubernetes_catalog.py b/sky/catalog/kubernetes_catalog.py
index 6be9f6f7c..06a76fb78 100644
--- a/sky/catalog/kubernetes_catalog.py
+++ b/sky/catalog/kubernetes_catalog.py
@@ -195,6 +195,10 @@ def _list_accelerators(
                 accelerator_name = lf.get_accelerator_from_label_value(
                     node.metadata.labels.get(key))
 
+                # Heterogenous cluster may have some nodes with empty labels.
+                if not accelerator_name:
+                    continue
+
                 # Exclude multi-host TPUs from being processed.
                 # TODO(Doyoung): Remove the logic when adding support for
                 # multi-host TPUs.
@@ -212,7 +216,7 @@ def _list_accelerators(
                     kubernetes_utils.get_node_accelerator_count(
                         node.status.allocatable))
 
-                if accelerator_name and accelerator_count > 0:
+                if accelerator_count > 0:
                     # TPUs are counted in a different way compared to GPUs.
                     # Multi-node GPUs can be split into smaller units and be
                     # provisioned, but TPUs are considered as an atomic unit.
diff --git a/sky/dashboard/src/components/config.js b/sky/dashboard/src/components/config.js
index cc69b6e78..f50948185 100644
--- a/sky/dashboard/src/components/config.js
+++ b/sky/dashboard/src/components/config.js
@@ -303,7 +303,7 @@ export function Config() {
             <Button
               onClick={handleSave}
               disabled={loading || saving}
-              className="inline-flex items-center bg-blue-600 hover:bg-blue-700 text-white"
+              className="inline-flex items-center bg-sky-600 hover:bg-sky-700 text-white"
             >
               {saving ? (
                 <>
diff --git a/sky/dashboard/src/components/workspace-editor.jsx b/sky/dashboard/src/components/workspace-editor.jsx
index b788ffcb0..8fefac748 100644
--- a/sky/dashboard/src/components/workspace-editor.jsx
+++ b/sky/dashboard/src/components/workspace-editor.jsx
@@ -43,7 +43,7 @@ import {
 import { ErrorDisplay } from '@/components/elements/ErrorDisplay';
 import { statusGroups } from './jobs'; // Import statusGroups
 import yaml from 'js-yaml';
-import { CLOUD_CONONICATIONS } from '@/data/connectors/constants';
+import { CLOUD_CANONICALIZATIONS } from '@/data/connectors/constants';
 import { getUsers } from '@/data/connectors/users';
 
 // Success display component
@@ -93,7 +93,7 @@ const WorkspaceConfigDescription = ({
     }
 
     const cloudName =
-      CLOUD_CONONICATIONS[cloud.toLowerCase()] || cloud.toUpperCase();
+      CLOUD_CANONICALIZATIONS[cloud.toLowerCase()] || cloud.toUpperCase();
     const isActuallyEnabled = enabledCloudsSet.has(cloudName?.toLowerCase());
 
     if (cloudConfig?.disabled === true) {
@@ -180,13 +180,13 @@ const WorkspaceConfigDescription = ({
 const WorkspaceBadge = ({ isPrivate }) => {
   if (isPrivate) {
     return (
-      <span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-amber-100 text-amber-800 border border-amber-300">
+      <span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-700 border border-gray-300">
         Private
       </span>
     );
   }
   return (
-    <span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 border border-green-300">
+    <span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-700 border border-green-300">
       Public
     </span>
   );
@@ -799,7 +799,7 @@ export function WorkspaceEditor({ workspaceName, isNewWorkspace = false }) {
                           <Button
                             onClick={handleSave}
                             disabled={saving || yamlError || loading}
-                            className="inline-flex items-center bg-blue-600 hover:bg-blue-700 text-white"
+                            className="inline-flex items-center bg-sky-600 hover:bg-sky-700 text-white"
                           >
                             <SaveIcon className="w-4 h-4 mr-1.5" />
                             {saving ? 'Applying...' : 'Apply'}
diff --git a/sky/dashboard/src/components/workspace-new.js b/sky/dashboard/src/components/workspace-new.js
index edce6c2d7..77c4dda89 100644
--- a/sky/dashboard/src/components/workspace-new.js
+++ b/sky/dashboard/src/components/workspace-new.js
@@ -109,7 +109,7 @@ export function NewWorkspace() {
           <Button
             onClick={handleNext}
             disabled={!isFormValid || loading}
-            className="w-full bg-blue-600 hover:bg-blue-700 text-white disabled:bg-gray-300 disabled:text-gray-500"
+            className="w-full bg-sky-600 hover:bg-sky-700 text-white disabled:bg-gray-300 disabled:text-gray-500"
           >
             {loading ? 'Loading...' : 'Next: Configure Workspace'}
           </Button>
diff --git a/sky/dashboard/src/components/workspaces.jsx b/sky/dashboard/src/components/workspaces.jsx
index 7879de895..5fc770845 100644
--- a/sky/dashboard/src/components/workspaces.jsx
+++ b/sky/dashboard/src/components/workspaces.jsx
@@ -16,6 +16,14 @@ import {
   CardTitle,
   CardFooter,
 } from '@/components/ui/card';
+import {
+  Table,
+  TableHeader,
+  TableRow,
+  TableHead,
+  TableBody,
+  TableCell,
+} from '@/components/ui/table';
 import { Button } from '@/components/ui/button';
 import { CircularProgress } from '@mui/material';
 import yaml from 'js-yaml';
@@ -34,13 +42,16 @@ import {
   TickIcon,
 } from '@/components/elements/icons';
 import { ErrorDisplay } from '@/components/elements/ErrorDisplay';
-import { RotateCwIcon } from 'lucide-react';
+import { RotateCwIcon, PlusIcon, Trash2Icon, EditIcon } from 'lucide-react';
 import { useMobile } from '@/hooks/useMobile';
 import { statusGroups } from './jobs';
 import dashboardCache from '@/lib/cache';
 import { REFRESH_INTERVALS } from '@/lib/config';
 import cachePreloader from '@/lib/cache-preloader';
 import { apiClient } from '@/data/connectors/client';
+import { sortData } from '@/data/utils';
+import { CLOUD_CANONICALIZATIONS } from '@/data/connectors/constants';
+import Link from 'next/link';
 
 // Workspace configuration description component
 const WorkspaceConfigDescription = ({ workspaceName, config }) => {
@@ -126,176 +137,18 @@ const WorkspaceConfigDescription = ({ workspaceName, config }) => {
 const WorkspaceBadge = ({ isPrivate }) => {
   if (isPrivate) {
     return (
-      <span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-amber-100 text-amber-800 border border-amber-300">
+      <span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-700 border border-gray-300">
         Private
       </span>
     );
   }
   return (
-    <span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 border border-green-300">
+    <span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-700 border border-green-300">
       Public
     </span>
   );
 };
 
-// Workspace card component
-const WorkspaceCard = ({
-  workspace,
-  onDelete,
-  onEdit,
-  router,
-  rawWorkspacesData,
-  checkPermissionAndAct,
-  roleLoading,
-}) => {
-  const handleEdit = () => {
-    checkPermissionAndAct('cannot edit workspace', () => {
-      onEdit(workspace.name);
-    });
-  };
-
-  // Get the workspace configuration to check if it's private
-  const workspaceConfig = rawWorkspacesData?.[workspace.name] || {};
-  const isPrivate = workspaceConfig.private === true;
-
-  return (
-    <Card key={workspace.name}>
-      <CardHeader>
-        <CardTitle className="text-base font-normal">
-          <div className="flex items-center justify-between">
-            <div>
-              <span className="font-semibold">Workspace:</span> {workspace.name}
-            </div>
-            <WorkspaceBadge isPrivate={isPrivate} />
-          </div>
-        </CardTitle>
-      </CardHeader>
-      <CardContent className="text-sm pb-2">
-        <div className="py-2 flex items-center justify-between">
-          <div className="flex items-center text-gray-600">
-            <ServerIcon className="w-4 h-4 mr-2 text-gray-500" />
-            <span>Clusters (Running / Total)</span>
-          </div>
-          <button
-            onClick={() => {
-              router.push({
-                pathname: '/clusters',
-                query: { workspace: workspace.name },
-              });
-            }}
-            className="font-normal text-blue-600 hover:text-blue-800 hover:underline cursor-pointer"
-          >
-            {workspace.runningClusterCount} / {workspace.totalClusterCount}
-          </button>
-        </div>
-        <div className="py-2 flex items-center justify-between border-t border-gray-100">
-          <div className="flex items-center text-gray-600">
-            <BriefcaseIcon className="w-4 h-4 mr-2 text-gray-500" />
-            <span>Managed Jobs</span>
-          </div>
-          <button
-            onClick={() => {
-              router.push({
-                pathname: '/jobs',
-                query: { workspace: workspace.name },
-              });
-            }}
-            className="font-normal text-blue-600 hover:text-blue-800 hover:underline cursor-pointer"
-          >
-            {workspace.managedJobsCount}
-          </button>
-        </div>
-      </CardContent>
-
-      <div className="px-6 pb-3 text-sm pt-3">
-        <h4 className="mb-2 text-xs text-gray-500 tracking-wider">
-          Enabled Infra
-        </h4>
-        <div className="flex flex-wrap gap-x-4 gap-y-1">
-          {workspace.clouds.map((cloud) => (
-            <div key={cloud} className="flex items-center text-gray-700">
-              <TickIcon className="w-3.5 h-3.5 mr-1.5 text-green-500" />
-              <span>{cloud}</span>
-            </div>
-          ))}
-        </div>
-      </div>
-
-      <CardFooter className="flex justify-end pt-3 gap-2">
-        <Button
-          variant="outline"
-          size="sm"
-          onClick={() => onDelete(workspace.name)}
-          disabled={workspace.name === 'default' || roleLoading}
-          title={
-            workspace.name === 'default'
-              ? 'Cannot delete default workspace'
-              : 'Delete workspace'
-          }
-          className="text-red-600 hover:text-red-700 hover:bg-red-50"
-        >
-          {roleLoading ? (
-            <div className="flex items-center">
-              <CircularProgress size={12} className="mr-1" />
-              <span>Delete</span>
-            </div>
-          ) : (
-            'Delete'
-          )}
-        </Button>
-        <Button
-          variant="outline"
-          size="sm"
-          onClick={handleEdit}
-          disabled={roleLoading}
-        >
-          {roleLoading ? (
-            <div className="flex items-center">
-              <CircularProgress size={12} className="mr-1" />
-              <span>Edit</span>
-            </div>
-          ) : (
-            'Edit'
-          )}
-        </Button>
-      </CardFooter>
-    </Card>
-  );
-};
-
-// Create new workspace card component
-const CreateWorkspaceCard = ({
-  onClick,
-  checkPermissionAndAct,
-  roleLoading,
-}) => {
-  const handleClick = () => {
-    checkPermissionAndAct('cannot create workspace', onClick);
-  };
-
-  return (
-    <Card
-      key="create-new"
-      className="border-2 border-dashed border-sky-300 hover:border-sky-400 cursor-pointer transition-colors flex flex-col"
-      onClick={handleClick}
-    >
-      <div className="flex-1 flex items-center justify-center p-6">
-        <div className="text-center">
-          <div className="w-16 h-16 rounded-full bg-sky-100 flex items-center justify-center mb-4 mx-auto">
-            <span className="text-3xl text-sky-600">+</span>
-          </div>
-          <h3 className="text-lg font-medium text-sky-700 mb-2">
-            Create New Workspace
-          </h3>
-          <p className="text-sm text-gray-500">
-            Set up a new workspace with custom infrastructure configurations
-          </p>
-        </div>
-      </div>
-    </Card>
-  );
-};
-
 // Statistics summary component
 const StatsSummary = ({
   workspaceCount,
@@ -357,6 +210,15 @@ export function Workspaces() {
   const [loading, setLoading] = useState(true);
   const [rawWorkspacesData, setRawWorkspacesData] = useState(null);
 
+  // Sorting state
+  const [sortConfig, setSortConfig] = useState({
+    key: 'name',
+    direction: 'asc',
+  });
+
+  // Search state
+  const [searchQuery, setSearchQuery] = useState('');
+
   // Modal states
   const [isAllWorkspacesModalOpen, setIsAllWorkspacesModalOpen] =
     useState(false);
@@ -580,6 +442,65 @@ export function Workspaces() {
     return () => clearInterval(interval);
   }, []);
 
+  // Sorting functionality
+  const handleSort = (key) => {
+    let direction = 'asc';
+    if (sortConfig.key === key && sortConfig.direction === 'asc') {
+      direction = 'desc';
+    }
+    setSortConfig({ key, direction });
+  };
+
+  const getSortDirection = (key) => {
+    if (sortConfig.key === key) {
+      return sortConfig.direction === 'asc' ? ' ↑' : ' ↓';
+    }
+    return '';
+  };
+
+  const sortedWorkspaces = React.useMemo(() => {
+    if (!workspaceDetails) return [];
+
+    // First apply search filter
+    let filtered = workspaceDetails;
+    if (searchQuery && searchQuery.trim() !== '') {
+      const searchLower = searchQuery.toLowerCase().trim();
+      filtered = workspaceDetails.filter((workspace) => {
+        // Check workspace name
+        if (workspace.name.toLowerCase().includes(searchLower)) {
+          return true;
+        }
+
+        // Check infrastructure clouds (both original and canonical names)
+        if (
+          workspace.clouds.some((cloud) => {
+            const canonicalCloudName =
+              CLOUD_CANONICALIZATIONS[cloud.toLowerCase()] || cloud;
+            return (
+              cloud.toLowerCase().includes(searchLower) ||
+              canonicalCloudName.toLowerCase().includes(searchLower)
+            );
+          })
+        ) {
+          return true;
+        }
+
+        // Check public/private status
+        const workspaceConfig = rawWorkspacesData?.[workspace.name] || {};
+        const isPrivate = workspaceConfig.private === true;
+        const status = isPrivate ? 'private' : 'public';
+        if (status.includes(searchLower)) {
+          return true;
+        }
+
+        return false;
+      });
+    }
+
+    // Then apply sorting
+    return sortData(filtered, sortConfig.key, sortConfig.direction);
+  }, [workspaceDetails, sortConfig, searchQuery, rawWorkspacesData]);
+
   const handleDeleteWorkspace = (workspaceName) => {
     checkPermissionAndAct('cannot delete workspace', () => {
       setDeleteState({
@@ -646,9 +567,15 @@ export function Workspaces() {
     });
   };
 
-  const handleEditAllConfigs = () => {
-    checkPermissionAndAct('cannot edit config', () => {
-      router.push('/config');
+  const handleCreateWorkspace = () => {
+    checkPermissionAndAct('cannot create workspace', () => {
+      router.push('/workspace/new');
+    });
+  };
+
+  const handleEditWorkspace = (workspaceName) => {
+    checkPermissionAndAct('cannot edit workspace', () => {
+      router.push(`/workspaces/${workspaceName}`);
     });
   };
 
@@ -726,31 +653,11 @@ export function Workspaces() {
           onDismiss={() => setTopLevelError(null)}
         />
       </div>
+
       {/* Header */}
-      <div className="flex items-center justify-between mb-4 h-5">
+      <div className="flex items-center justify-between mb-2 h-5">
         <div className="text-base flex items-center">
           <span className="text-sky-blue leading-none">Workspaces</span>
-          <Button
-            variant="outline"
-            size="sm"
-            onClick={handleEditAllConfigs}
-            className="ml-4 px-2 py-1 text-xs"
-            disabled={
-              loading ||
-              roleLoading ||
-              !rawWorkspacesData ||
-              Object.keys(rawWorkspacesData).length === 0
-            }
-          >
-            {roleLoading ? (
-              <div className="flex items-center">
-                <CircularProgress size={12} className="mr-1" />
-                <span>Edit All Configs</span>
-              </div>
-            ) : (
-              'Edit All Configs'
-            )}
-          </Button>
         </div>
         <div className="flex items-center">
           {loading && (
@@ -770,16 +677,61 @@ export function Workspaces() {
         </div>
       </div>
 
-      {/* Statistics Summary */}
-      <StatsSummary
-        workspaceCount={workspaceDetails.length}
-        runningClusters={globalStats.runningClusters}
-        totalClusters={globalStats.totalClusters}
-        managedJobs={globalStats.managedJobs}
-        router={router}
-      />
+      {/* Search and Create Workspace Row */}
+      <div className="flex items-center justify-between mb-4">
+        <div className="relative flex-1 max-w-md">
+          <input
+            type="text"
+            placeholder="Filter workspaces"
+            value={searchQuery}
+            onChange={(e) => setSearchQuery(e.target.value)}
+            className="h-8 w-full px-3 pr-8 text-sm border border-gray-300 rounded-md focus:ring-1 focus:ring-sky-500 focus:border-sky-500 outline-none"
+          />
+          {searchQuery && (
+            <button
+              onClick={() => setSearchQuery('')}
+              className="absolute right-2 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
+              title="Clear search"
+            >
+              <svg
+                className="h-4 w-4"
+                fill="none"
+                stroke="currentColor"
+                viewBox="0 0 24 24"
+              >
+                <path
+                  strokeLinecap="round"
+                  strokeLinejoin="round"
+                  strokeWidth={2}
+                  d="M6 18L18 6M6 6l12 12"
+                />
+              </svg>
+            </button>
+          )}
+        </div>
 
-      {/* Workspace Cards */}
+        {/* Create Workspace Button */}
+        <button
+          onClick={handleCreateWorkspace}
+          disabled={roleLoading}
+          className="ml-4 bg-sky-600 hover:bg-sky-700 text-white flex items-center rounded-md px-3 py-1 text-sm font-medium transition-colors duration-200"
+          title="Create Workspace"
+        >
+          {roleLoading ? (
+            <>
+              <CircularProgress size={12} className="mr-2" />
+              <span>Create Workspace</span>
+            </>
+          ) : (
+            <>
+              <PlusIcon className="h-4 w-4 mr-2" />
+              Create Workspace
+            </>
+          )}
+        </button>
+      </div>
+
+      {/* Workspaces Table */}
       {workspaceDetails.length === 0 && !loading ? (
         <div className="text-center py-10">
           <p className="text-lg text-gray-600">No workspaces found.</p>
@@ -788,25 +740,167 @@ export function Workspaces() {
           </p>
         </div>
       ) : (
-        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
-          {workspaceDetails.map((ws) => (
-            <WorkspaceCard
-              key={ws.name}
-              workspace={ws}
-              onDelete={handleDeleteWorkspace}
-              onEdit={(name) => router.push(`/workspaces/${name}`)}
-              router={router}
-              rawWorkspacesData={rawWorkspacesData}
-              checkPermissionAndAct={checkPermissionAndAct}
-              roleLoading={roleLoading}
-            />
-          ))}
-          <CreateWorkspaceCard
-            onClick={() => router.push('/workspace/new')}
-            checkPermissionAndAct={checkPermissionAndAct}
-            roleLoading={roleLoading}
-          />
-        </div>
+        <Card>
+          <div className="overflow-x-auto rounded-lg">
+            <Table className="min-w-full">
+              <TableHeader>
+                <TableRow>
+                  <TableHead
+                    className="sortable whitespace-nowrap cursor-pointer hover:bg-gray-50"
+                    onClick={() => handleSort('name')}
+                  >
+                    Workspace{getSortDirection('name')}
+                  </TableHead>
+                  <TableHead
+                    className="sortable whitespace-nowrap cursor-pointer hover:bg-gray-50 hidden sm:table-cell"
+                    onClick={() => handleSort('totalClusterCount')}
+                  >
+                    Clusters {getSortDirection('totalClusterCount')}
+                  </TableHead>
+                  <TableHead
+                    className="sortable whitespace-nowrap cursor-pointer hover:bg-gray-50 hidden md:table-cell"
+                    onClick={() => handleSort('managedJobsCount')}
+                  >
+                    Jobs{getSortDirection('managedJobsCount')}
+                  </TableHead>
+                  <TableHead className="whitespace-nowrap hidden lg:table-cell">
+                    Enabled infra
+                  </TableHead>
+                  <TableHead className="whitespace-nowrap">Actions</TableHead>
+                </TableRow>
+              </TableHeader>
+              <TableBody>
+                {loading && sortedWorkspaces.length === 0 ? (
+                  <TableRow>
+                    <TableCell
+                      colSpan={5}
+                      className="text-center py-6 text-gray-500"
+                    >
+                      <div className="flex justify-center items-center">
+                        <CircularProgress size={20} className="mr-2" />
+                        <span>Loading...</span>
+                      </div>
+                    </TableCell>
+                  </TableRow>
+                ) : sortedWorkspaces.length > 0 ? (
+                  sortedWorkspaces.map((workspace) => {
+                    // Get the workspace configuration to check if it's private
+                    const workspaceConfig =
+                      rawWorkspacesData?.[workspace.name] || {};
+                    const isPrivate = workspaceConfig.private === true;
+
+                    return (
+                      <TableRow
+                        key={workspace.name}
+                        className="hover:bg-gray-50"
+                      >
+                        <TableCell className="">
+                          <button
+                            onClick={() => handleEditWorkspace(workspace.name)}
+                            disabled={roleLoading}
+                            className="text-blue-600 hover:text-blue-600 hover:underline text-left"
+                          >
+                            {workspace.name}
+                          </button>
+                          <span className="ml-2">
+                            <WorkspaceBadge isPrivate={isPrivate} />
+                          </span>
+                        </TableCell>
+                        <TableCell className="hidden sm:table-cell">
+                          <button
+                            onClick={() => {
+                              router.push({
+                                pathname: '/clusters',
+                                query: { workspace: workspace.name },
+                              });
+                            }}
+                            className="text-gray-700 hover:text-blue-600 hover:underline"
+                          >
+                            {workspace.runningClusterCount} running,{' '}
+                            {workspace.totalClusterCount} total
+                          </button>
+                        </TableCell>
+                        <TableCell className="hidden md:table-cell">
+                          <button
+                            onClick={() => {
+                              router.push({
+                                pathname: '/jobs',
+                                query: { workspace: workspace.name },
+                              });
+                            }}
+                            className="text-gray-700 hover:text-blue-600 hover:underline"
+                          >
+                            {workspace.managedJobsCount}
+                          </button>
+                        </TableCell>
+                        <TableCell className="hidden lg:table-cell">
+                          {workspace.clouds.length > 0 ? (
+                            [...workspace.clouds].sort().map((cloud, index) => {
+                              const canonicalCloudName =
+                                CLOUD_CANONICALIZATIONS[cloud.toLowerCase()] ||
+                                cloud;
+                              return (
+                                <span key={cloud}>
+                                  <Link
+                                    href="/infra"
+                                    className="inline-flex items-center px-2 py-1 rounded text-sm bg-sky-100 text-sky-800 hover:bg-sky-200 hover:text-sky-900 transition-colors duration-200"
+                                  >
+                                    {canonicalCloudName}
+                                  </Link>
+                                  {index < workspace.clouds.length - 1 && ' '}
+                                </span>
+                              );
+                            })
+                          ) : (
+                            <span className="text-gray-500 text-sm">-</span>
+                          )}
+                        </TableCell>
+                        <TableCell>
+                          <Button
+                            variant="ghost"
+                            size="sm"
+                            onClick={() => handleEditWorkspace(workspace.name)}
+                            disabled={roleLoading}
+                            className="text-gray-600 hover:text-gray-800 mr-1"
+                          >
+                            <EditIcon className="w-4 h-4" />
+                          </Button>
+                          <Button
+                            variant="ghost"
+                            size="sm"
+                            onClick={() =>
+                              handleDeleteWorkspace(workspace.name)
+                            }
+                            disabled={
+                              workspace.name === 'default' || roleLoading
+                            }
+                            title={
+                              workspace.name === 'default'
+                                ? 'Cannot delete default workspace'
+                                : 'Delete workspace'
+                            }
+                            className="text-red-600 hover:text-red-700 hover:bg-red-50"
+                          >
+                            <Trash2Icon className="w-4 h-4" />
+                          </Button>
+                        </TableCell>
+                      </TableRow>
+                    );
+                  })
+                ) : (
+                  <TableRow>
+                    <TableCell
+                      colSpan={5}
+                      className="text-center py-6 text-gray-500"
+                    >
+                      No workspaces found
+                    </TableCell>
+                  </TableRow>
+                )}
+              </TableBody>
+            </Table>
+          </div>
+        </Card>
       )}
 
       {/* All Workspaces Config Modal */}
diff --git a/sky/dashboard/src/data/connectors/constants.jsx b/sky/dashboard/src/data/connectors/constants.jsx
index f1c668ff4..c51d4c30e 100644
--- a/sky/dashboard/src/data/connectors/constants.jsx
+++ b/sky/dashboard/src/data/connectors/constants.jsx
@@ -24,7 +24,7 @@ export const CLOUDS_LIST = [
   'Nebius',
 ];
 
-export const CLOUD_CONONICATIONS = Object.fromEntries([
+export const CLOUD_CANONICALIZATIONS = Object.fromEntries([
   ...CLOUDS_LIST.map((cloud) => [cloud.toLowerCase(), cloud]),
   ['kubernetes', 'Kubernetes'],
   ['ssh', 'SSH Node Pool'],
diff --git a/sky/provision/kubernetes/utils.py b/sky/provision/kubernetes/utils.py
index a328659ec..83a4fc178 100644
--- a/sky/provision/kubernetes/utils.py
+++ b/sky/provision/kubernetes/utils.py
@@ -554,6 +554,9 @@ class GKELabelFormatter(GPULabelFormatter):
             return acc
         elif is_tpu_on_gke(value):
             return value
+        elif value == '':
+            # heterogenous cluster may have empty labels for cpu nodes.
+            return ''
         else:
             raise ValueError(
                 f'Invalid accelerator name in GKE cluster: {value}')
@@ -993,6 +996,9 @@ class GKEAutoscaler(Autoscaler):
             node_accelerator_type = (
                 GKELabelFormatter.get_accelerator_from_label_value(
                     accelerator['acceleratorType']))
+            # handle heterogenous nodes.
+            if not node_accelerator_type:
+                continue
             node_accelerator_count = accelerator['acceleratorCount']
             if node_accelerator_type == requested_gpu_type and int(
                     node_accelerator_count) >= requested_gpu_count:


Test patch:
diff --git a/tests/unit_tests/kubernetes/test_kubernetes_utils.py b/tests/unit_tests/kubernetes/test_kubernetes_utils.py
index 431387026..22adaa429 100644
--- a/tests/unit_tests/kubernetes/test_kubernetes_utils.py
+++ b/tests/unit_tests/kubernetes/test_kubernetes_utils.py
@@ -12,6 +12,7 @@ import pytest
 
 from sky import exceptions
 from sky import models
+from sky.catalog import kubernetes_catalog
 from sky.provision.kubernetes import utils
 
 
@@ -305,3 +306,54 @@ def test_detect_gpu_label_formatter_invalid_label_skip():
         lf, _ = utils.detect_gpu_label_formatter('whatever')
         assert lf is not None
         assert isinstance(lf, utils.CoreWeaveLabelFormatter)
+
+
+# pylint: disable=line-too-long
+def test_heterogenous_gpu_detection_key_counts():
+    """Tests that a heterogenous gpu cluster with empty
+    labels are correctly processed."""
+
+    mock_node1 = mock.MagicMock()
+    mock_node1.metadata.name = 'node1'
+    mock_node1.metadata.labels = {
+        'cloud.google.com/gke-accelerator': 'nvidia-h100-80gb',
+        'gpu.nvidia.com/class': 'nvidia-h100-80gb',
+        'gpu.nvidia.com/count': '1',
+        'gpu.nvidia.com/model': 'nvidia-h100-80gb',
+        'gpu.nvidia.com/vram': '81'
+    }
+    mock_node1.status.allocatable = {'nvidia.com/gpu': '1'}
+
+    mock_node2 = mock.MagicMock()
+    mock_node2.metadata.name = 'node2'
+    mock_node2.metadata.labels = {'cloud.google.com/gke-accelerator': ''}
+
+    mock_container1 = mock.MagicMock()
+    mock_container1.resources.requests = 0
+
+    mock_pod1 = mock.MagicMock()
+    mock_pod1.spec.node_name = 'node1'
+    mock_pod1.status.phase = 'Running'
+    mock_pod1.spec.containers = [mock_container1]
+
+    mock_container2 = mock.MagicMock()
+    mock_container2.resources.requests = 0
+
+    mock_pod2 = mock.MagicMock()
+    mock_pod2.spec.node_name = 'node2'
+    mock_pod2.status.phase = 'Running'
+    mock_pod2.spec.containers = [mock_container2]
+
+    with mock.patch('sky.clouds.cloud_in_iterable', return_value=True), \
+         mock.patch('sky.provision.kubernetes.utils.get_current_kube_config_context_name', return_value='doesntexist'), \
+         mock.patch('sky.provision.kubernetes.utils.check_credentials', return_value=[True]), \
+         mock.patch('sky.provision.kubernetes.utils.detect_accelerator_resource', return_value=True), \
+         mock.patch('sky.provision.kubernetes.utils.detect_gpu_label_formatter', return_value=[utils.GKELabelFormatter(), None]), \
+         mock.patch('sky.provision.kubernetes.utils.get_kubernetes_nodes', return_value=[mock_node1, mock_node2]), \
+         mock.patch('sky.provision.kubernetes.utils.get_all_pods_in_kubernetes_cluster', return_value=[mock_pod1, mock_pod2]):
+
+        counts, capacity, available = kubernetes_catalog.list_accelerators_realtime(
+            True, None, None, None)
+        assert (set(counts.keys()) == set(capacity.keys()) == set(available.keys())), \
+            (f'Keys of counts ({list(counts.keys())}), capacity ({list(capacity.keys())}), '
+             f'and available ({list(available.keys())}) must be the same.')
diff --git a/tests/unit_tests/test_catalog.py b/tests/unit_tests/test_catalog.py
new file mode 100644
index 000000000..75047db9f
--- /dev/null
+++ b/tests/unit_tests/test_catalog.py
@@ -0,0 +1,85 @@
+import os
+import tempfile
+import time
+from unittest import mock
+
+import pandas as pd
+import pytest
+
+from sky.catalog import common as catalog_common
+from sky.utils import annotations
+
+
+@mock.patch('sky.catalog.common.requests.get')
+def test_read_catalog_triggers_update_on_stale_file(mock_get):
+    """Test that read_catalog (and the LazyDataFrame it returns)
+    does an update when the catalog file is stale, and that
+    it's cached for the duration of the request."""
+    DUMMY_CSV = 'col1,col2\n1,2\n3,4\n'
+    NEW_DUMMY_CSV = 'col1,col2\n5,6\n7,8\n'
+
+    class DummyResponse:
+
+        def __init__(self, text, status_code=200):
+            self.text = text
+            self.status_code = status_code
+
+        def raise_for_status(self):
+            if self.status_code != 200:
+                raise Exception('HTTP error')
+
+    mock_get.return_value = DummyResponse(DUMMY_CSV)
+
+    # Create a random file name.
+    with tempfile.NamedTemporaryFile(delete=True) as tmp:
+        filename = os.path.join(f'gcp/{os.path.basename(tmp.name)}.csv')
+
+    try:
+        df = catalog_common.read_catalog(filename, pull_frequency_hours=1)
+        # Force the CSV to be written to disk.
+        df.head()
+
+        # The file on disk and the DataFrame should match DUMMY_CSV.
+        abs_catalog_path = catalog_common.get_catalog_path(filename)
+        assert os.path.exists(abs_catalog_path)
+        with open(abs_catalog_path) as f:
+            content_on_disk = f.read()
+        assert content_on_disk == DUMMY_CSV
+        pd.testing.assert_frame_equal(df._df, pd.read_csv(abs_catalog_path))
+
+        # Modify the file's mtime to be 2 hours ago.
+        new_time = time.time() - 60 * 60 * 2
+        os.utime(abs_catalog_path, (new_time, new_time))
+
+        # Patch requests.get again to return NEW_DUMMY_CSV.
+        mock_get.return_value = DummyResponse(NEW_DUMMY_CSV)
+
+        # We haven't cleared annotations.FUNCTIONS_NEED_RELOAD_CACHE,
+        # so _load_df should still be cached and update_if_stale_func
+        # should not be called, i.e. the file on disk and
+        # DataFrame should not be updated.
+        df.head()
+        pd.testing.assert_frame_equal(df._df, pd.read_csv(abs_catalog_path))
+
+        # Clear the cache.
+        for func in annotations.FUNCTIONS_NEED_RELOAD_CACHE:
+            func.cache_clear()
+
+        # Now update_if_stale_func should be called and should trigger a new fetch.
+        df.head()
+        # The file and DataFrame should match NEW_DUMMY_CSV.
+        with open(abs_catalog_path) as f:
+            content_on_disk = f.read()
+        assert content_on_disk == NEW_DUMMY_CSV
+        pd.testing.assert_frame_equal(df._df, pd.read_csv(abs_catalog_path))
+    finally:
+        if os.path.exists(abs_catalog_path):
+            os.remove(abs_catalog_path)
+        meta_path = os.path.join(catalog_common._ABSOLUTE_VERSIONED_CATALOG_DIR,
+                                 '.meta', filename + '.md5')
+        if os.path.exists(meta_path):
+            os.remove(meta_path)
+        lock_path = os.path.join(catalog_common._ABSOLUTE_VERSIONED_CATALOG_DIR,
+                                 '.meta', filename + '.lock')
+        if os.path.exists(lock_path):
+            os.remove(lock_path)