Skip to content
This repository was archived by the owner on Feb 6, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions app/web/src/newhotness/ComponentDetails.vue
Original file line number Diff line number Diff line change
Expand Up @@ -310,11 +310,15 @@
><span class="text-sm">Qualifications</span></template
>
<template #headerIcons>
<MinimizedComponentQualificationStatus :component="component" />
<MinimizedComponentQualificationStatus
:component="component"
:dependentValues="dependentValues"
/>
</template>
<QualificationPanel
:component="component"
:attributeTree="attributeTree || undefined"
:attributeTree="attributeTree"
:dependentValues="dependentValues"
/>
</CollapsingFlexItem>
<CollapsingFlexItem ref="connectionsFlex" expandable>
Expand Down Expand Up @@ -448,6 +452,7 @@ import {
import {
AttributeTree,
BifrostComponent,
DependentValues,
EntityKind,
IncomingConnections,
MgmtFuncKind,
Expand Down Expand Up @@ -557,6 +562,13 @@ const attributeTreeQuery = useQuery<AttributeTree | null>({
});
const attributeTree = computed(() => attributeTreeQuery.data.value);

const dependentValuesQuery = useQuery<DependentValues | null>({
queryKey: key(EntityKind.DependentValues),
queryFn: async () =>
await bifrost<DependentValues>(args(EntityKind.DependentValues)),
});
const dependentValues = computed(() => dependentValuesQuery.data.value);

// Determines if the component is able to have a resource. For example, for "AWS::EC2::KeyPair",
// this would return 'true', but fore "AWS Region", this would return false.
const hasResourceValueProps = computed(() => {
Expand Down
11 changes: 10 additions & 1 deletion app/web/src/newhotness/MinimizedComponentQualificationStatus.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<template>
<div class="flex flex-row justify-between">
<div class="flex flex-row items-center gap-xs">
<template v-if="!noText">
<template v-if="!noText && !inProgress">
<TextPill
v-if="
component.qualificationTotals.failed === 0 &&
Expand Down Expand Up @@ -57,15 +57,24 @@ import { computed } from "vue";
import {
ComponentInList,
BifrostComponent,
DependentValues,
} from "@/workers/types/entity_kind_types";
import StatusIndicatorIcon from "@/components/StatusIndicatorIcon.vue";

const props = defineProps<{
component: ComponentInList | BifrostComponent;
dependentValues?: DependentValues | null;
noText?: boolean;
}>();

const inProgress = computed(() =>
props.dependentValues?.componentAttributes?.[props.component.id]?.includes(
"/qualification",
),
);

const qualificationStatus = computed(() => {
if (inProgress.value) return "running";
if (props.component.qualificationTotals.failed > 0) return "failure";
if (props.component.qualificationTotals.warned > 0) return "warning";
if (props.component.qualificationTotals.succeeded > 0) return "success";
Expand Down
60 changes: 29 additions & 31 deletions app/web/src/newhotness/QualificationPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { computed } from "vue";
import {
AttributeTree,
BifrostComponent,
DependentValues,
} from "@/workers/types/entity_kind_types";
import QualificationView from "@/newhotness/QualificationView.vue";
import { AttributeValueId } from "./types";
Expand All @@ -37,54 +38,51 @@ export interface Qualification {
// This exists so the validation qualification can pass in its output, since that comes baked in the data
// We should avoid using it for normal qualifications, for which QualificationView will lazily fetch the output
output?: null | string[];
/** Indicates whether the qualification is dirty (running or waiting to run) */
isDirty?: boolean;
}

const props = defineProps<{
component: BifrostComponent;
attributeTree?: AttributeTree;
attributeTree?: AttributeTree | null;
dependentValues?: DependentValues | null;
}>();

const root = computed(() => props.attributeTree);

const qualifications = computed<Qualification[]>(() => {
const items: Qualification[] = [];
const r = root.value;
if (!r) return items;
const data = findAvsAtPropPath(r, [
const root = props.attributeTree;
if (!root) return [];

const qualificationItems = findAvsAtPropPath(root, [
"root",
"qualification",
"qualificationItem",
]);
if (data) {
const { attributeValues } = data;

attributeValues.forEach((av) => {
const name = av.key;
const children = r.treeInfo[av.id]?.children ?? [];
let status;
let message;
children.forEach((avId) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const child = r.attributeValues[avId]!;
if (child.path?.endsWith("result")) status = child.value;
else if (child.path?.endsWith("message")) message = child.value;
});
items.push({
avId: av.id,
name,
status,
message,
});
});
}
if (!qualificationItems) return [];
const items = qualificationItems.attributeValues.map((av) => {
const qualification = { avId: av.id, name: av.key } as Qualification;
// Set result and message based on the AttributeTree
for (const avId of root.treeInfo[av.id]?.children ?? []) {
const child = root.attributeValues[avId];
// TODO should we set both if they both exist?
if (child?.path?.endsWith("result"))
qualification.status = child.value as QualificationStatus;
else if (child?.path?.endsWith("message"))
qualification.message = child.value as string;
}
// Set isDirty based on dependentValues
qualification.isDirty = props.dependentValues?.componentAttributes[
props.component.id
]?.includes(av.path);
return qualification;
});

// Since we have all the data locally, we compute the validation rollup qualification over here
// The qualification also gets computed in the backed for the old UI and luminork, so at some point we may
// revisit this, but this works well.
let hasValidations = false;
const validationOutput: string[] = [];
Object.values(r.attributeValues).forEach((av) => {
const prop = r.props[av.propId ?? ""];
Object.values(root.attributeValues).forEach((av) => {
const prop = root.props[av.propId ?? ""];
if (!av.validation || !prop) return;
hasValidations = true;

Expand Down
10 changes: 8 additions & 2 deletions app/web/src/newhotness/QualificationView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@
:status="qualificationStatus ?? 'unknown'"
class="break-all"
>
<template v-if="qualificationStatus === 'success'"> Passed! </template>
<template v-if="qualification.isDirty">
The qualification has not yet run or is actively running.
</template>
<template v-else-if="qualificationStatus === 'success'">
Passed!
</template>
<div
v-else-if="
qualificationStatus === 'failure' ||
Expand All @@ -38,8 +43,9 @@
@click="toggleHidden"
/>
</div>
<!-- Note: this should only really happen if it's a new component and the MVs aren't up to date yet -->
<template v-else>
The qualification has not yet ran or is actively running.
The qualification has not yet run or is actively running.
</template>
</StatusMessageBox>

Expand Down
13 changes: 10 additions & 3 deletions app/web/src/workers/types/entity_kind_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { ChangeSetId } from "@/api/sdf/dal/change_set";
import { DefaultMap } from "@/utils/defaultmap";
import { ComponentName } from "@/store/components.store";
import { WorkspacePk } from "@/newhotness/types";
import { SecretId } from "@/store/realtime/realtime_events";
import { ComponentInfo } from "./dbinterface";

export enum EntityKind {
Expand All @@ -36,6 +37,7 @@ export enum EntityKind {
ComponentsInViews = "ComponentsInViews",
DefaultSubscriptions = "DefaultSubscriptions",
DependentValueComponentList = "DependentValueComponentList",
DependentValues = "DependentValues",
ErasedComponents = "ErasedComponents",
IncomingConnections = "IncomingConnections",
IncomingConnectionsList = "IncomingConnectionsList",
Expand Down Expand Up @@ -220,7 +222,7 @@ export interface SchemaMembers {
}

export interface UninstalledVariant {
schemaId: string;
schemaId: SchemaId;
schemaName: string;
displayName: string | null;
category: string;
Expand Down Expand Up @@ -666,7 +668,7 @@ export interface SecretDefinition {

export interface Secret {
// The [`id`](SecretId) of a [`Secret`].
id: string;
id: SecretId;
// The name of a [`Secret`] as provided by the user.
name: string;
// The definition of a [`Secret`].
Expand All @@ -678,10 +680,15 @@ export interface Secret {
}

export interface DependentValueComponentList {
id: string;
id: WorkspacePk;
componentIds: ComponentId[];
}

export interface DependentValues {
id: WorkspacePk;
componentAttributes: Record<ComponentId, AttributePath[]>;
}

export interface DefaultSubscription {
componentId: ComponentId;
path: string;
Expand Down
39 changes: 39 additions & 0 deletions lib/dal-materialized-views/src/dependent_values.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
use dal::{
AttributeValue,
DalContext,
attribute::value::DependentValueGraph,
workspace_snapshot::DependentValueRoot,
};
use si_frontend_mv_types::dependent_values::DependentValues as DependentValuesMv;
use telemetry::prelude::*;

#[instrument(
name = "dal_materialized_views.dependent_values",
level = "debug",
skip_all
)]
pub async fn assemble(ctx: DalContext) -> super::Result<DependentValuesMv> {
let ctx = &ctx;
let dependent_value_graph = DependentValueGraph::new(
ctx,
DependentValueRoot::get_dependent_value_roots(ctx).await?,
)
.await?;

let mut result = DependentValuesMv {
id: ctx.workspace_pk()?,
component_attributes: Default::default(),
};

for dependent_value in dependent_value_graph.all_value_ids() {
let (component_id, path) =
AttributeValue::path_from_component(ctx, dependent_value.attribute_value_id()).await?;
result
.component_attributes
.entry(component_id)
.or_default()
.push(path);
}

Ok(result)
}
1 change: 1 addition & 0 deletions lib/dal-materialized-views/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ pub mod cached;
pub mod component;
pub mod component_list;
pub mod dependent_value_component_list;
pub mod dependent_values;
pub mod incoming_connections;
pub mod incoming_connections_list;
pub mod luminork;
Expand Down
9 changes: 9 additions & 0 deletions lib/dal/src/attribute/value.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2477,6 +2477,15 @@ impl AttributeValue {
Ok((child_id, pointer.to_string()))
}

pub async fn path_from_component(
ctx: &DalContext,
child_av_id: AttributeValueId,
) -> AttributeValueResult<(ComponentId, String)> {
let (root_av_id, path) = Self::path_from_root(ctx, child_av_id).await?;
let component_id = Self::component_id(ctx, root_av_id).await?;
Ok((component_id, path))
}

/// Get a subscription to this AV, from its root AV.
pub async fn as_subscription(
ctx: &DalContext,
Expand Down
22 changes: 21 additions & 1 deletion lib/edda-server/src/materialized_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ use si_frontend_mv_types::{
component_diff::ComponentDiff as ComponentDiffMv,
erased_components::ErasedComponents as ErasedComponentsMv,
},
dependent_values::DependentValueComponentList as DependentValueComponentListMv,
dependent_values::{
DependentValueComponentList as DependentValueComponentListMv,
DependentValues as DependentValuesMv,
},
incoming_connections::{
IncomingConnections as IncomingConnectionsMv,
IncomingConnectionsList as IncomingConnectionsListMv,
Expand Down Expand Up @@ -650,6 +653,23 @@ pub async fn spawn_build_mv_task_for_change_and_mv_kind(
(*maybe_mv_index).clone(),
);
}
ReferenceKind::DependentValues => {
let workspace_mv_id = workspace_pk.to_string();
metric!(
counter.edda.mv_build = 1,
label = format!("{workspace_pk}:{change_set_id_for_metrics_only}:{mv_kind}")
);
spawn_build_mv_task!(
build_tasks,
ctx,
frigg,
change,
workspace_mv_id,
DependentValuesMv,
dal_materialized_views::dependent_values::assemble(ctx.clone()),
(*maybe_mv_index).clone(),
);
}
ReferenceKind::IncomingConnections => {
let entity_mv_id = change.entity_id.to_string();
metric!(
Expand Down
2 changes: 1 addition & 1 deletion lib/si-events-rs/src/materialized_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
/// * List
/// * Detail
///
/// The order of the priorities is determined by the descriminant, largest to smallest.
/// The order of the priorities is determined by the discriminant, largest to smallest.
pub enum BuildPriority {
#[default]
Detail = 5,
Expand Down
32 changes: 31 additions & 1 deletion lib/si-frontend-mv-types-rs/src/dependent_values.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
use serde::Serialize;
use std::collections::HashMap;

use serde::{
Deserialize,
Serialize,
};
use si_events::workspace_snapshot::EntityKind;
use si_id::{
ComponentId,
Expand All @@ -10,6 +15,7 @@ use crate::reference::ReferenceKind;
#[derive(
Debug,
Serialize,
Deserialize,
PartialEq,
Eq,
Clone,
Expand All @@ -28,3 +34,27 @@ pub struct DependentValueComponentList {
pub id: WorkspacePk,
pub component_ids: Vec<ComponentId>,
}

#[derive(
Debug,
Serialize,
Deserialize,
PartialEq,
Eq,
Clone,
si_frontend_mv_types_macros::MV,
si_frontend_mv_types_macros::FrontendChecksum,
si_frontend_mv_types_macros::FrontendObject,
si_frontend_mv_types_macros::Refer,
)]
#[serde(rename_all = "camelCase")]
#[mv(
trigger_entity = EntityKind::CategoryDependentValueRoots,
reference_kind = ReferenceKind::DependentValues,
build_priority = "List",
)]
pub struct DependentValues {
pub id: WorkspacePk,
/// Mapping from component ID to the list of "dirty" attribute paths in that component.
pub component_attributes: HashMap<ComponentId, Vec<String>>,
}
Loading