-
Notifications
You must be signed in to change notification settings - Fork 182
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Render loading/error states on catalog and details pages #3770
base: main
Are you sure you want to change the base?
Render loading/error states on catalog and details pages #3770
Conversation
Codecov ReportAttention: Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #3770 +/- ##
==========================================
+ Coverage 84.34% 84.70% +0.36%
==========================================
Files 1487 1512 +25
Lines 34078 34970 +892
Branches 9440 9796 +356
==========================================
+ Hits 28744 29623 +879
- Misses 5334 5347 +13
... and 133 files with indirect coverage changes Continue to review full report in Codecov by Sentry.
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great work! I'd remove the linter overrides though :)
I haven't tested this yet, I'll run it against a cluster this afternoon.
modelCatalog.findModelCatalogEmptyState().should('exist'); | ||
}); | ||
|
||
it('should show empty state when configmap has malformed data', () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm curious why we show the empty state rather than the error state when the configmap is garbage?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@mturley I think he can answer this better
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hmm. Honestly it probably does make sense to render an error if there is malformed JSON... I'm not sure what I was thinking there 👀 maybe that it might be an ugly error? I think I'm just used to always making sure to catch JSON.parse exceptions, but in this case since it's in a fetch callback letting that error be thrown is probably a good idea so the admin messing with the configmap can see what they did wrong.
Sorry @YuliaKrimerman .... can you just remove the innermost try/catch
around the JSON.parse? We can proceed as if it succeeded and let an exception there cause the fetch promise to be rejected. We should also update that test case to look for the rendered error.
// Swallow JSON parse errors and return empty array for temporary summit implementation | ||
return []; | ||
const configMap = await getConfigMap(dashboardNamespace, MODEL_CATALOG_SOURCE_CONFIGMAP); | ||
/* eslint-enable @typescript-eslint/no-unsafe-member-access */ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Curious as to why this linter override is necessary?
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions | ||
const source = JSON.parse(configMap.data.modelCatalogSource) as ModelCatalogSource; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You don't need this linter override here if you specify the type of source.
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions | |
const source = JSON.parse(configMap.data.modelCatalogSource) as ModelCatalogSource; | |
const source: ModelCatalogSource = JSON.parse(configMap.data.modelCatalogSource); |
if ( | ||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions | ||
'statusObject' in (e as object) && | ||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions | ||
isK8sStatus((e as { statusObject: unknown }).statusObject) | ||
) { | ||
return []; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You don't need the linter overrides here either, as long as you have enough guards to narrow the type down tightly enough you won't need the as
keyword, which we generally want to avoid like the plague as that really has the effect of saying to the type system: "I've got this, kick back and chill for a bit", so we're losing compile time type safety.
If you actually narrow the type through conditional statements rather than using as
, you're proving to the compiler the code is type safe, so any unexpected runtime values will be handled.
Try something like this, it's not pretty, but it's type safe and avoids the linter overrides:
if ( | |
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions | |
'statusObject' in (e as object) && | |
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions | |
isK8sStatus((e as { statusObject: unknown }).statusObject) | |
) { | |
return []; | |
} | |
// Check if error is a 404 | |
if ( | |
typeof e === 'object' && | |
e != null && | |
'statusObject' in e && | |
isK8sStatus(e.statusObject) | |
) { | |
return []; | |
} |
[APPROVALNOTIFIER] This PR is NOT APPROVED This pull-request has been approved by: The full list of commands accepted by this bot can be found here.
Needs approval from an approver in each of these files:
Approvers can indicate their approval by writing |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@YuliaKrimerman this is great work - just a few things we need to tweak. You've also got some merge conflicts here.
typeof e === 'object' && | ||
e != null && | ||
'statusObject' in e && | ||
isK8sStatus(e.statusObject) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's not enough to just check that we have a k8s Status object - we also need to check that e.statusObject.code === 404
here. Non-404 errors at the k8s API level will also have a statusObject
with a different code (e.g. 403 if our rolebinding is messed up and the user doesn't have access to the configmap).
@YuliaKrimerman can you also update your non-404 error test case so it has a Status like the 404 case, but the status code is 500 or 401 or something else?
ns: 'opendatahub', | ||
name: 'model-catalog-source-redhat', | ||
}, | ||
{ statusCode: 500 }, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the line I'm referring to - this error response should have a Status object too.
it('should show error state when configmap fetch fails (non-404)', () => { | ||
cy.interceptK8s( | ||
{ | ||
model: ConfigMapModel, | ||
ns: 'opendatahub', | ||
name: 'model-catalog-source-redhat', | ||
}, | ||
{ | ||
statusCode: 500, | ||
body: { | ||
kind: 'Status', | ||
apiVersion: 'v1', | ||
status: 'Failure', | ||
message: 'Internal server error', | ||
reason: 'InternalError', | ||
code: 500, | ||
}, | ||
}, | ||
); | ||
|
||
modelDetailsPage.visit(); | ||
cy.contains('Unable to load model details').should('exist'); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
EDIT --- we may not need any changes here. I think this test is passing because of the incorrect contents being rendered in emptyStatePage. See comments below before you try to change anything here.
Hang on... I'm confused about why this test is passing. You have the non-404 Status object like we need to add to the other non-404 test here, but (see my earlier comment) your current code is eating errors and returning []
if they have a statusObject even if it's not 404. This test is written correctly but it should be failing (and adding the && e.statusObject.code === 404
in useModelCatalogSources like I mentioned should fix it).
I wonder if the problem is that you have two cy.interceptK8s
calls for this configmap (one in initIntercepts
and one in the test) and Cypress is using the first intercept for some reason? I wonder, does this test fail if you comment out the configmap intercept in initIntercepts
so this is the only intercept?
Maybe we should add a param to initIntercepts
like includeConfigMap = true
, and then in these describe
blocks around the tests that have their own configmap intercepts we should change the beforeEach
so it passes true there and there is only ever one configmap intercept per test. We probably want to do that in the test files for both of these pages.
If that's not the issue, I'm not sure why this test would be passing...
if (isModelCatalogSource(parsed)) { | ||
return [parsed]; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we either need to make isModelCatalogSource
actually inspect the properties inside the object to make sure they match the type, or we need to remove it and not bother checking. I'd lean towards the latter.
A lot of the dashboard code makes the assumption that the API will return data that matches our TypeScript types - actually checking all the types at run time isn't practical. We are curating the contents of the configmap and not supporting manual edits to it, so it's essentially our API contract - it's editable by a cluster admin in case they really need to squeeze another model into the Summit demo, but normal users won't be editing it. I think it's safe to assume it's correctly formatted as long as it's valid JSON (which it is if JSON.parse didn't throw an exception).
I think it would be fine to get rid of isModelCatalogSource
and just do this:
return parsed ? [parsed as ModelCatalogSource] : [];
I'm curious if @alexcreasy disagrees.
Alternatively, we could have isModelCatalogSource
also check that obj.models.length > 0
?
@@ -15,16 +15,35 @@ const ModelCatalog: React.FC = conditionalArea( | |||
)(() => { | |||
const { modelCatalogSources } = React.useContext(ModelCatalogContext); | |||
const renderStateProps = { | |||
empty: modelCatalogSources.data.length === 0, | |||
empty: modelCatalogSources.data.length === 0 || !modelCatalogSources.data[0]?.models?.length, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is kinda funky - ideally we shouldn't write anything outside of useModelCatalogSources
that assumes we have only one source (since we'll be adding a second source soon for Neural Magic), so we don't want to do anything based on only looking at data[0]
. I think we can assume that if we have sources they have models, so we can probably remove this line.
(Note: this is why I added the end of my comment above about isModelCatalogSource
- if you do want to check that a source has models before trying to use it, we should do that check there instead).
title={ | ||
modelCatalogSources.error | ||
? 'Unable to load model catalog' | ||
: 'Request access to model catalog' | ||
} | ||
description={ | ||
modelCatalogSources.error | ||
? 'Refresh the page or try again later' | ||
: 'To request access to model catalog, contact your administrator.' | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We don't need to check anything related to modelCatalogSources.error
in the emptyStatePage
-- if there's an error, the loadError
prop below will be true and we won't be rendering this empty state at all. Instead, ApplicationsPage will use its internal error rendering which will use your errorMessage
prop below.
We can put the whole EmptyModelCatalogState back the way it was here - the loadError
and errorMessage
props of ApplicationsPage are sufficient for rendering errors on these pages.
@@ -33,8 +52,6 @@ const ModelCatalog: React.FC = conditionalArea( | |||
<TitleWithIcon title="Model Catalog" objectType={ProjectObjectType.registeredModels} /> | |||
} | |||
{...renderStateProps} | |||
loaded={modelCatalogSources.loaded} | |||
provideChildrenPadding |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why remove the provideChildrenPadding
prop?
@@ -15,16 +15,35 @@ const ModelCatalog: React.FC = conditionalArea( | |||
)(() => { | |||
const { modelCatalogSources } = React.useContext(ModelCatalogContext); | |||
const renderStateProps = { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we can take this whole renderStateProps
object and move its stuff as directly passing props to ApplicationsPage below, like we have for ModelDetailsPage. It was lifted out in other pages because it was conditional / it would vary, but there's no reason for that here.
@@ -42,18 +42,20 @@ const ModelDetailsPage: React.FC = conditionalArea( | |||
objectType={ProjectObjectType.registeredModels} | |||
/> | |||
} | |||
empty={model === null} | |||
empty={Boolean(modelCatalogSources.error) || model === null} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Similar to the other page - ApplicationsPage renders errors before it renders empty. We're passing loadError={modelCatalogSources.error}
, so if modelCatalogSources.error
is defined, emptyStatePage
is not going to be rendered regardless of the value of empty
. So we can change this back to model === null
.
emptyStatePage={ | ||
<EmptyModelCatalogState | ||
testid="empty-model-catalog-state" | ||
title="Details not found" | ||
description="To request access to model catalog, contact your administrator." | ||
title={modelCatalogSources.error ? 'Details not found' : 'Unable to load model details'} | ||
description={modelCatalogSources.error?.message || 'Refresh the page or try again later'} | ||
headerIcon={() => ( | ||
<img src={typedEmptyImage(ProjectObjectType.registeredModels)} alt="" /> | ||
<img src={typedEmptyImage(ProjectObjectType.registeredModels, 'Error')} alt="" /> | ||
)} | ||
/> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Similarly here, we don't need to do any of this error rendering in emptyStatePage. You can put this all back as it was - it is only used if there are no model catalog sources.
Aha -- I know why your mysterious non-404 cypress test was passing when it shouldn't. You're rendering 'Unable to load model details'
here in the empty state even though there is no error. If you revert this stuff, that test will fail as it should. I'll edit the earlier comment.
Closes https://issues.redhat.com/browse/RHOAIENG-18960
Description
Added empty and loading states for both Model Catalog and Model Catalog Details page.
Screen.Recording.2025-02-14.at.11.34.25.AM.mov
How Has This Been Tested?
Test Impact
Added test cases to test all scenarios :
-if there is a non-404 error loading the configmap, we show an error message
-if the configmap is present but has empty data (data: { modelCatalogSource: '[]' }), we show the empty state
-if the configmap is present but has malformed data / bad JSON (data: { modelCatalogSource: 'invalid JSON here' } , we show the empty state
-if the configmap is present and has good data (the intercept you have now), we show the actual model details / cards
Request review criteria:
Self checklist (all need to be checked):
If you have UI changes:
After the PR is posted & before it merges:
main