diff --git a/frontend/src/components/feedback/FeedbackCard.css b/frontend/src/components/feedback/FeedbackCard.css
new file mode 100644
index 0000000..0c4332c
--- /dev/null
+++ b/frontend/src/components/feedback/FeedbackCard.css
@@ -0,0 +1,57 @@
+
+.feedback-card {
+ min-height: 220px;
+ padding: 32px 20px;
+
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+
+ text-align: center;
+
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+}
+
+.feedback-card > svg {
+ color: var(--accent);
+}
+
+.feedback-card--error {
+ border-color: #78424c;
+}
+
+.feedback-card--error > svg {
+ color: #fb7185;
+}
+
+.feedback-card h2 {
+ margin: 0;
+ color: var(--text-heading);
+ font-size: 20px;
+}
+
+.feedback-card p {
+ margin: 0;
+ color: var(--text);
+ font-size: 14px;
+}
+
+.feedback-spinner {
+ animation: feedback-spin 1s linear infinite;
+}
+
+@keyframes feedback-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .feedback-spinner {
+ animation: none;
+ }
+}
diff --git a/frontend/src/components/feedback/FeedbackCard.tsx b/frontend/src/components/feedback/FeedbackCard.tsx
new file mode 100644
index 0000000..098f9f7
--- /dev/null
+++ b/frontend/src/components/feedback/FeedbackCard.tsx
@@ -0,0 +1,39 @@
+
+import { AlertCircle, LoaderCircle, SearchX } from "lucide-react";
+import "./FeedbackCard.css";
+
+type FeedbackCardProps = {
+ variant: "loading" | "error" | "empty";
+ title: string;
+ message?: string;
+};
+
+function FeedbackCard({ variant, title, message }: FeedbackCardProps) {
+
+ const icons = {
+ loading: LoaderCircle,
+ error: AlertCircle,
+ empty: SearchX,
+ };
+
+ const Icon = icons[variant];
+
+ return (
+
+
+
+ {title}
+
+ {message && {message}
}
+
+ );
+}
+
+export default FeedbackCard;
diff --git a/frontend/src/components/threatactor/ThreatActorDetailed/ThreatActorAssociated/ThreatActorAssociatedList.tsx b/frontend/src/components/threatactor/ThreatActorDetailed/ThreatActorAssociated/ThreatActorAssociatedList.tsx
index 177f0e6..b8e97c5 100644
--- a/frontend/src/components/threatactor/ThreatActorDetailed/ThreatActorAssociated/ThreatActorAssociatedList.tsx
+++ b/frontend/src/components/threatactor/ThreatActorDetailed/ThreatActorAssociated/ThreatActorAssociatedList.tsx
@@ -6,6 +6,7 @@ import {
} from "lucide-react";
import type { AssociatedItem } from "../../../../types/ThreatActorTypes";
import "./ThreatActorAssociatedList.css";
+import FeedbackCard from "../../../feedback/FeedbackCard";
type ThreatActorAssociatedListProps = {
type: "techniques" | "software";
@@ -30,6 +31,18 @@ function ThreatActorAssociatedList({type,items,totalCount,page,pageSize,onPageCh
+ {/* Vis empty state dersom ingen tilknyttede entiteter finnes */}
+ {totalCount === 0 && (
+
+ )}
{items.map((item) => (
diff --git a/frontend/src/pages/ThreatActors/ThreatActorDetailed.tsx b/frontend/src/pages/ThreatActors/ThreatActorDetailed.tsx
index e561fdc..3553dda 100644
--- a/frontend/src/pages/ThreatActors/ThreatActorDetailed.tsx
+++ b/frontend/src/pages/ThreatActors/ThreatActorDetailed.tsx
@@ -1,7 +1,7 @@
import ThreatActorDetailedHeader from "../../components/threatactor/ThreatActorDetailed/ThreatActorDetailedHeader/ThreatActorDetailedHeader";
import ThreatActorOverview from "../../components/threatactor/ThreatActorDetailed/ThreatActorDetailedOverview/ThreatActorOverview";
import ThreatActorDetailedInfoCards from "../../components/threatactor/ThreatActorDetailed/ThreatActorDetailedInfoCards/ThreatActorDetailedInfoCards";
-
+import FeedbackCard from "../../components/feedback/FeedbackCard";
import "./ThreatActorDetailed.css";
import ThreatActorDetailedDescription from "../../components/threatactor/ThreatActorDetailed/ThreatActorDetailedDescription/ThreatActorDetailedDescription";
import { useParams } from "react-router-dom";
@@ -30,7 +30,7 @@ function ThreatActorDetailed() {
);
//Henter ut Assosierte Techniques
- const {data: techniquesData} = useQuery(GET_THREAT_ACTOR_TECHNIQUES, {
+ const {data: techniquesData, loading: techniquesLoading, error: techniquesError} = useQuery(GET_THREAT_ACTOR_TECHNIQUES, {
variables: {
mitreId: mitreId ?? "",
limit: PAGE_SIZE,
@@ -40,7 +40,7 @@ function ThreatActorDetailed() {
});
//Henter ut Assosierte Softwares
- const {data: softwaresData} = useQuery(GET_THREAT_ACTOR_SOFTWARES, {
+ const {data: softwaresData, loading: softwaresLoading, error: softwaresError} = useQuery(GET_THREAT_ACTOR_SOFTWARES, {
variables: {
mitreId: mitreId ?? "",
limit: PAGE_SIZE,
@@ -48,26 +48,39 @@ function ThreatActorDetailed() {
},
skip: !mitreId,
});
-
+
if (!mitreId) {
- return
Missing Threat Actor ID.
;
+ return
;
}
if (detailedLoading) {
- return
Loading Threat Actor...
;
+ return
;
}
if (detailedError) {
- return
Error loading Threat Actor: {detailedError.message}
;
+ return
;
}
const actor = detailedData?.getThreatActorByMitreId;
- if(!actor){
- return
Threat Actor not found.
;
+ if (!actor) {
+ return
;
}
-
return (
@@ -106,25 +119,57 @@ function ThreatActorDetailed() {
xMitreDeprecated={actor.xMitreDeprecated}
/>
-
-
-
-
-
+
+
+
+ {/* Associated Techniques */}
+ {techniquesLoading ? (
+
+ ) : techniquesError ? (
+
+ ) : (
+
+ )}
+
+ {/* Associated Software */}
+ {softwaresLoading ? (
+
+ ) : softwaresError ? (
+
+ ) : (
+
+ )}
+
+
+
diff --git a/frontend/src/pages/ThreatActors/ThreatActors.tsx b/frontend/src/pages/ThreatActors/ThreatActors.tsx
index 20de402..6b43380 100644
--- a/frontend/src/pages/ThreatActors/ThreatActors.tsx
+++ b/frontend/src/pages/ThreatActors/ThreatActors.tsx
@@ -7,6 +7,7 @@ import { useQuery } from "@apollo/client/react";
import { GET_ALL_THREAT_ACTOR_GRID, GET_THREAT_ACTOR_FILTER_OPTIONS } from "../../graphql/queries/threatActorQueries";
import { useState } from "react";
import ThreatActorActiveFilters from "../../components/threatactor/ThreatActorMain/ThreatActorFilter/ThreatActorActiveFilters";
+import FeedbackCard from "../../components/feedback/FeedbackCard";
@@ -81,7 +82,7 @@ function ThreatActor(){
const { sortBy, ...filters } = activeFilters;
- const {data} = useQuery(GET_ALL_THREAT_ACTOR_GRID, {
+ const {data, loading, error} = useQuery(GET_ALL_THREAT_ACTOR_GRID, {
variables: {
limit: pageSize,
offset: (page - 1) * pageSize,
@@ -97,7 +98,7 @@ function ThreatActor(){
const actorsCount = data?.allThreatActorsGrid.totalCount ?? 0;
//Henter ut data for filter
- const {data: filterData} = useQuery(GET_THREAT_ACTOR_FILTER_OPTIONS, {fetchPolicy: "cache-first"})
+ const {data: filterData, loading: filtersLoading, error: filtersError} = useQuery(GET_THREAT_ACTOR_FILTER_OPTIONS, {fetchPolicy: "cache-first"})
const filterOptions = filterData?.threatActorFilterOptions;
return (
@@ -109,12 +110,27 @@ function ThreatActor(){
onSearch={handleSearch}
/>
-
+ {filtersError ? (
+
+ ) : filtersLoading && !filterOptions ? (
+
+ ) : (
+
+ )}
+
-
+ {loading ? (
+
+ ) : error ? (
+
+ ) : actors.length === 0 ? (
+
+ ) : (
+
+ )}
);
}