diff --git a/client/src/components/Common/FilterMenu.vue b/client/src/components/Common/FilterMenu.vue index 75272a7d2e5f..7a62a29ee3d0 100644 --- a/client/src/components/Common/FilterMenu.vue +++ b/client/src/components/Common/FilterMenu.vue @@ -13,6 +13,8 @@ import DelayedInput from "@/components/Common/DelayedInput.vue"; import FilterMenuBoolean from "@/components/Common/FilterMenuBoolean.vue"; import FilterMenuInput from "@/components/Common/FilterMenuInput.vue"; import FilterMenuMultiTags from "@/components/Common/FilterMenuMultiTags.vue"; +import FilterMenuObjectStore from "@/components/Common/FilterMenuObjectStore.vue"; +import FilterMenuQuotaSource from "@/components/Common/FilterMenuQuotaSource.vue"; import FilterMenuRanged from "@/components/Common/FilterMenuRanged.vue"; library.add(faAngleDoubleUp, faQuestion, faRedo, faSearch); @@ -206,6 +208,19 @@ watch( :filters="filters" :identifier="identifier" @change="onOption" /> + + +import { storeToRefs } from "pinia"; +import { computed, ref, watch } from "vue"; + +import { useObjectStoreStore } from "@/stores/objectStoreStore"; +import { ValidFilter } from "@/utils/filtering"; + +import FilterObjectStoreLink from "./FilterObjectStoreLink.vue"; + +type FilterType = string | boolean | undefined; + +interface Props { + name: string; + filter: ValidFilter; + filters: { + [k: string]: FilterType; + }; +} + +const props = defineProps(); + +const emit = defineEmits<{ + (e: "change", name: string, value: FilterType): void; +}>(); + +const propValue = computed(() => props.filters[props.name]); + +const localValue = ref(propValue.value); + +watch( + () => localValue.value, + (newFilter: FilterType) => { + emit("change", props.name, newFilter); + } +); +watch( + () => propValue.value, + (newFilter: FilterType) => { + localValue.value = newFilter; + } +); + +const store = useObjectStoreStore(); +const { selectableObjectStores } = storeToRefs(store); + +const hasObjectStores = computed(() => { + return selectableObjectStores.value && selectableObjectStores.value.length > 0; +}); + +function onChange(value: string | null) { + localValue.value = (value || undefined) as FilterType; +} + + + diff --git a/client/src/components/Common/FilterMenuQuotaSource.vue b/client/src/components/Common/FilterMenuQuotaSource.vue new file mode 100644 index 000000000000..eaf57766055a --- /dev/null +++ b/client/src/components/Common/FilterMenuQuotaSource.vue @@ -0,0 +1,101 @@ + + + diff --git a/client/src/components/Common/FilterObjectStoreLink.vue b/client/src/components/Common/FilterObjectStoreLink.vue new file mode 100644 index 000000000000..65860cd057cc --- /dev/null +++ b/client/src/components/Common/FilterObjectStoreLink.vue @@ -0,0 +1,51 @@ + + + diff --git a/client/src/components/Common/ObjectStoreSelect.vue b/client/src/components/Common/ObjectStoreSelect.vue new file mode 100644 index 000000000000..3ded3de27b2e --- /dev/null +++ b/client/src/components/Common/ObjectStoreSelect.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/client/src/components/History/HistoryFilters.js b/client/src/components/History/HistoryFilters.js index 051ca0a5450d..9bfdbcddef51 100644 --- a/client/src/components/History/HistoryFilters.js +++ b/client/src/components/History/HistoryFilters.js @@ -1,6 +1,14 @@ import { STATES } from "components/History/Content/model/states"; import StatesInfo from "components/History/Content/model/StatesInfo"; -import Filtering, { compare, contains, equals, expandNameTag, toBool, toDate } from "utils/filtering"; +import Filtering, { + compare, + contains, + equals, + expandNameTag, + quotaSourceFilter, + toBool, + toDate, +} from "utils/filtering"; const excludeStates = ["empty", "failed", "upload", "placeholder", "failed_populated_state", "new_populated_state"]; const states = Object.keys(STATES).filter((state) => !excludeStates.includes(state)); @@ -19,6 +27,32 @@ const validFilters = { }, genome_build: { placeholder: "database", type: String, handler: contains("genome_build"), menuItem: true }, related: { placeholder: "related", type: Number, handler: equals("related"), menuItem: true }, + object_store_id: { + placeholder: "object store", + type: "ObjectStore", + handler: equals("object_store_id"), + menuItem: true, + }, + quota_source: { + placeholder: "quota source", + type: "QuotaSource", + handler: quotaSourceFilter("quota_source_label", (qs) => { + if (!qs) { + return null; + } + if (typeof qs === "string" || qs instanceof String) { + return qs; + } + const label = qs.rawSourceLabel; + if (label == null) { + // API uses this as the label for the label-less default quota + return "__null__"; + } else { + return label; + } + }), + menuItem: true, + }, hid: { placeholder: "index", type: Number, handler: equals("hid"), isRangeInput: true, menuItem: true }, hid_ge: { handler: compare("hid", "ge"), menuItem: false }, hid_le: { handler: compare("hid", "le"), menuItem: false }, diff --git a/client/src/components/User/DiskUsage/DiskUsageSummary.vue b/client/src/components/User/DiskUsage/DiskUsageSummary.vue index 658f097a5179..f17dc84d8688 100644 --- a/client/src/components/User/DiskUsage/DiskUsageSummary.vue +++ b/client/src/components/User/DiskUsage/DiskUsageSummary.vue @@ -2,14 +2,15 @@ import { storeToRefs } from "pinia"; import { computed, onMounted, ref, watch } from "vue"; -import { fetchQuotaUsages, recalculateDiskUsage } from "@/api/users"; +import { recalculateDiskUsage } from "@/api/users"; import { useConfig } from "@/composables/config"; import { useTaskMonitor } from "@/composables/taskMonitor"; import { useUserStore } from "@/stores/userStore"; import { errorMessageAsString } from "@/utils/simple-error"; import { bytesToString } from "@/utils/utils"; -import { QuotaUsage, UserQuotaUsageData } from "./Quota/model"; +import { QuotaUsage } from "./Quota/model"; +import { fetch } from "./Quota/services"; import QuotaUsageSummary from "@/components/User/DiskUsage/Quota/QuotaUsageSummary.vue"; @@ -74,8 +75,7 @@ async function onRefresh() { async function loadQuotaUsages() { try { - const { data } = await fetchQuotaUsages({ user_id: "current" }); - quotaUsages.value = data.map((u: UserQuotaUsageData) => new QuotaUsage(u)); + quotaUsages.value = await fetch(); } catch (e) { errorMessage.value = errorMessageAsString(e); } diff --git a/client/src/components/User/DiskUsage/Quota/model/QuotaUsage.ts b/client/src/components/User/DiskUsage/Quota/model/QuotaUsage.ts index a7e30991519c..8e8a1c16d016 100644 --- a/client/src/components/User/DiskUsage/Quota/model/QuotaUsage.ts +++ b/client/src/components/User/DiskUsage/Quota/model/QuotaUsage.ts @@ -15,11 +15,15 @@ export class QuotaUsage { this._data = data; } + get rawSourceLabel(): string | null { + return this._data.quota_source_label ?? null; + } + /** * The name of the ObjectStore associated with the quota. */ get sourceLabel(): string { - return this._data.quota_source_label ?? DEFAULT_QUOTA_SOURCE_LABEL; + return this.rawSourceLabel ?? DEFAULT_QUOTA_SOURCE_LABEL; } /** diff --git a/client/src/components/User/DiskUsage/Quota/services.ts b/client/src/components/User/DiskUsage/Quota/services.ts new file mode 100644 index 000000000000..c9353d6170a6 --- /dev/null +++ b/client/src/components/User/DiskUsage/Quota/services.ts @@ -0,0 +1,8 @@ +import { fetchQuotaUsages } from "@/api/users"; + +import { QuotaUsage, UserQuotaUsageData } from "./model/index"; + +export async function fetch() { + const { data } = await fetchQuotaUsages({ user_id: "current" }); + return data.map((u: UserQuotaUsageData) => new QuotaUsage(u)); +} diff --git a/client/src/utils/filtering.ts b/client/src/utils/filtering.ts index f4c3af41393e..9683d19423ad 100644 --- a/client/src/utils/filtering.ts +++ b/client/src/utils/filtering.ts @@ -40,7 +40,7 @@ export type ValidFilter = { /** The `FilterMenu` input field/tooltip/label placeholder */ placeholder?: string; /** The data type of the `FilterMenu` input field */ - type?: typeof String | typeof Number | typeof Boolean | typeof Date | "MultiTags"; + type?: typeof String | typeof Number | typeof Boolean | typeof Date | "MultiTags" | "ObjectStore" | "QuotaSource"; /** If type: Boolean: * - booleanType: 'default' creates: `filter:true|false|any` * - booleanType: 'is' creates: `is:filter` @@ -153,6 +153,25 @@ export function equals(attribute: string, query?: string, converter?: Convert }; } +export function quotaSourceFilter(attribute: string, converter: Converter): HandlerReturn { + return { + attribute: attribute, + converter: converter, + query: `${attribute}-eq`, + handler: (v: T, q: T) => { + if (converter) { + v = converter(v); + q = converter(q); + } + function handleNullConversion(v: T) { + const lowerV = toLower(v); + return lowerV == "__null__" ? "null" : lowerV; + } + return handleNullConversion(v) === handleNullConversion(q); + }, + }; +} + /** * Checks if a query value is part of the item value * @param attribute of the content item diff --git a/lib/galaxy/managers/hdas.py b/lib/galaxy/managers/hdas.py index 6f574b96ab60..827826a07f36 100644 --- a/lib/galaxy/managers/hdas.py +++ b/lib/galaxy/managers/hdas.py @@ -482,6 +482,8 @@ def __init__(self, app: StructuredApp): "url", "create_time", "update_time", + "object_store_id", + "quota_source_label", ], ) self.add_view( @@ -595,6 +597,8 @@ def add_serializers(self): "api_type": lambda item, key, **context: "file", "type": lambda item, key, **context: "file", "created_from_basename": lambda item, key, **context: item.created_from_basename, + "object_store_id": lambda item, key, **context: item.object_store_id, + "quota_source_label": lambda item, key, **context: item.dataset.quota_source_label, "hashes": lambda item, key, **context: [h.to_dict() for h in item.hashes], "sources": lambda item, key, **context: [s.to_dict() for s in item.sources], "drs_id": lambda item, key, **context: f"hda-{self.app.security.encode_id(item.id, kind='drs')}", diff --git a/lib/galaxy/managers/history_contents.py b/lib/galaxy/managers/history_contents.py index b0a6e1007b5a..0a4a09f08f00 100644 --- a/lib/galaxy/managers/history_contents.py +++ b/lib/galaxy/managers/history_contents.py @@ -84,6 +84,7 @@ class HistoryContentsManager(base.SortableManager): "collection_id", "name", "state", + "object_store_id", "size", "deleted", "purged", @@ -354,6 +355,8 @@ def _contents_common_query_for_contained(self, history_id, user_id): history_content_type=literal("dataset"), size=model.Dataset.file_size, state=model.Dataset.state, + object_store_id=model.Dataset.object_store_id, + quota_source_label=model.Dataset.object_store_id, # do not have inner collections collection_id=literal(None), ) @@ -380,6 +383,8 @@ def _contents_common_query_for_subcontainer(self, history_id, user_id): dataset_id=literal(None), size=literal(None), state=model.DatasetCollection.populated_state, + object_store_id=literal(None), + quota_source_label=literal(None), # TODO: should be purgable? fix purged=literal(False), extension=literal(None), @@ -569,6 +574,30 @@ def get_filter(attr, op, val): return sql.column("state").in_(states) raise_filter_err(attr, op, val, "bad op in filter") + if attr == "object_store_id": + if op == "eq": + return sql.column("object_store_id") == val + + if op == "in": + object_store_ids = [s for s in val.split(",") if s] + return sql.column("object_store_id").in_(object_store_ids) + + raise_filter_err(attr, op, val, "bad op in filter") + + if attr == "quota_source_label": + if op == "eq": + ids = self.app.object_store.get_quota_source_map().ids_per_quota_source( + include_default_quota_source=True + ) + if val == "__null__": + val = None + if val not in ids: + raise KeyError(f"Could not find key {val} in object store keys {list(ids.keys())}") + object_store_ids = ids[val] + return sql.column("object_store_id").in_(object_store_ids) + + raise_filter_err(attr, op, val, "bad op in filter") + if (column_filter := get_filter(attr, op, val)) is not None: return self.parsed_filter(filter_type="orm", filter=column_filter) return super()._parse_orm_filter(attr, op, val) @@ -625,6 +654,8 @@ def _add_parsers(self): # 'hid-in' : { 'op': ( 'in' ), 'val': self.parse_int_list }, "name": {"op": ("eq", "contains", "like")}, "state": {"op": ("eq", "in")}, + "object_store_id": {"op": ("eq", "in")}, + "quota_source_label": {"op": ("eq")}, "visible": {"op": ("eq"), "val": parse_bool}, "create_time": {"op": ("le", "ge", "lt", "gt"), "val": self.parse_date}, "update_time": {"op": ("le", "ge", "lt", "gt"), "val": self.parse_date}, diff --git a/lib/galaxy/model/__init__.py b/lib/galaxy/model/__init__.py index 2d06c2d35ca0..1a8ca2c43523 100644 --- a/lib/galaxy/model/__init__.py +++ b/lib/galaxy/model/__init__.py @@ -4463,6 +4463,16 @@ def set_dataset_state(self, state): state = property(get_dataset_state, set_dataset_state) + def get_object_store_id(self): + return self.dataset.object_store_id + + object_store_id = property(get_object_store_id) + + def get_quota_source_label(self): + return self.dataset.quota_source_label + + quota_source_label = property(get_quota_source_label) + def get_file_name(self, sync_cache=True) -> str: if self.dataset.purged: return "" diff --git a/lib/galaxy/objectstore/__init__.py b/lib/galaxy/objectstore/__init__.py index 7fb4d9ea8790..13fed5fe0ddd 100644 --- a/lib/galaxy/objectstore/__init__.py +++ b/lib/galaxy/objectstore/__init__.py @@ -1589,16 +1589,20 @@ def default_usage_excluded_ids(self): exclude_object_store_ids.append(backend_id) return exclude_object_store_ids - def get_id_to_source_pairs(self): + def get_id_to_source_pairs(self, include_default_quota_source=False): pairs = [] for backend_id, backend_source_map in self.backends.items(): - if backend_source_map.default_quota_source is not None and backend_source_map.default_quota_enabled: + if ( + backend_source_map.default_quota_source is not None or include_default_quota_source + ) and backend_source_map.default_quota_enabled: pairs.append((backend_id, backend_source_map.default_quota_source)) return pairs - def ids_per_quota_source(self): - quota_sources: Dict[str, List[str]] = {} - for object_id, quota_source_label in self.get_id_to_source_pairs(): + def ids_per_quota_source(self, include_default_quota_source=False): + quota_sources: Dict[Optional[str], List[str]] = {} + for object_id, quota_source_label in self.get_id_to_source_pairs( + include_default_quota_source=include_default_quota_source + ): if quota_source_label not in quota_sources: quota_sources[quota_source_label] = [] quota_sources[quota_source_label].append(object_id) diff --git a/lib/galaxy_test/base/populators.py b/lib/galaxy_test/base/populators.py index e3976aa88ea9..3bad7b7f034f 100644 --- a/lib/galaxy_test/base/populators.py +++ b/lib/galaxy_test/base/populators.py @@ -1088,8 +1088,8 @@ def __history_content_id(self, history_id: str, wait=True, **kwds) -> str: history_content_id = history_contents[-1]["id"] return history_content_id - def get_history_contents(self, history_id: str) -> List[Dict[str, Any]]: - contents_response = self._get_contents_request(history_id) + def get_history_contents(self, history_id: str, data=None) -> List[Dict[str, Any]]: + contents_response = self._get_contents_request(history_id, data=data) contents_response.raise_for_status() return contents_response.json() diff --git a/test/integration/objectstore/test_changing_objectstore.py b/test/integration/objectstore/test_changing_objectstore.py index 24c88e025c4b..a0f63f63d7cb 100644 --- a/test/integration/objectstore/test_changing_objectstore.py +++ b/test/integration/objectstore/test_changing_objectstore.py @@ -74,12 +74,33 @@ def test_valid_in_device_swap(self): usage = self.dataset_populator.get_usage_for(None) assert int(usage["total_disk_usage"]) == 6 + def count_in_object_store(object_store_id: str): + return len( + self.dataset_populator.get_history_contents( + history_id, {"q": "object_store_id-eq", "qv": object_store_id, "v": "dev"} + ) + ) + + def count_in_quota_source(quota_source_label: str): + return len( + self.dataset_populator.get_history_contents( + history_id, {"q": "quota_source_label-eq", "qv": quota_source_label, "v": "dev"} + ) + ) + + self.dataset_populator.get_history_contents(history_id, {"q": "state-eq", "qv": "ok", "v": "dev"}) + assert count_in_object_store("temp_short") == 0 + assert count_in_quota_source("shorter_term") == 0 + self.dataset_populator.update_object_store_id(hda["id"], "temp_short") usage = self.dataset_populator.get_usage_for("shorter_term") assert int(usage["total_disk_usage"]) == 6 usage = self.dataset_populator.get_usage_for(None) assert int(usage["total_disk_usage"]) == 0 + assert count_in_object_store("temp_short") == 1 + assert count_in_quota_source("shorter_term") == 1 + self.dataset_populator.update_object_store_id(hda["id"], "temp_long") usage = self.dataset_populator.get_usage_for("shorter_term") assert int(usage["total_disk_usage"]) == 0