Skip to content

Commit 977ffcc

Browse files
authored
New team overview POC (#141)
POC
1 parent 5663ff5 commit 977ffcc

File tree

9 files changed

+742
-129
lines changed

9 files changed

+742
-129
lines changed

src/lib/components/AggregatedCostForTeam.svelte

+50-44
Original file line numberDiff line numberDiff line change
@@ -98,58 +98,64 @@
9898
}
9999
</script>
100100

101-
<div class="header">
102-
<Heading level="4" size="small" spacing>Cost</Heading>
103-
<HelpText title="Aggregated team cost"
104-
>Aggregated cost for team. Current month is estimated.</HelpText
105-
>
106-
</div>
107-
<GraphErrors errors={$costQuery.errors} />
101+
<div class="wrapper">
102+
<div class="header">
103+
<Heading level="4" size="small" spacing>Cost</Heading>
104+
<HelpText title="Aggregated team cost"
105+
>Aggregated cost for team. Current month is estimated.</HelpText
106+
>
107+
</div>
108+
<GraphErrors errors={$costQuery.errors} />
108109

109-
{#if $costQuery.data !== null}
110-
{@const cost = $costQuery.data.team.cost}
111-
<div>
112-
{#if cost.monthlySummary.series.length > 1}
113-
{@const factor = getFactor(cost.monthlySummary.series)}
114-
{#each cost.monthlySummary.series.slice(0, 2) as item (item)}
115-
{#if item.date.getDate() === new Date(item.date.getFullYear(), item.date.getMonth() + 1, 0).getDate()}
116-
{item.date.toLocaleString('en-GB', { month: 'long' })}: {euroValueFormatter(item.cost)}
117-
{:else}
118-
{item.date.toLocaleString('en-GB', { month: 'long' })}: {euroValueFormatter(
119-
getEstimateForMonth(item.cost, item.date)
120-
)}
121-
{#if factor > 1.0}
122-
(<span style="color: var(--a-surface-danger);">+{factor.toFixed(2)}%</span>)
110+
{#if $costQuery.data !== null}
111+
{@const cost = $costQuery.data.team.cost}
112+
<div>
113+
{#if cost.monthlySummary.series.length > 1}
114+
{@const factor = getFactor(cost.monthlySummary.series)}
115+
{#each cost.monthlySummary.series.slice(0, 2) as item (item)}
116+
{#if item.date.getDate() === new Date(item.date.getFullYear(), item.date.getMonth() + 1, 0).getDate()}
117+
{item.date.toLocaleString('en-GB', { month: 'long' })}: {euroValueFormatter(item.cost)}
123118
{:else}
124-
(<span style="color: var(--a-surface-success);">-{(1.0 - factor).toFixed(2)}%</span>)
119+
{item.date.toLocaleString('en-GB', { month: 'long' })}: {euroValueFormatter(
120+
getEstimateForMonth(item.cost, item.date)
121+
)}
122+
{#if factor > 1.0}
123+
(<span style="color: var(--a-surface-danger);">+{factor.toFixed(2)}%</span>)
124+
{:else}
125+
(<span style="color: var(--a-surface-success);">-{(1.0 - factor).toFixed(2)}%</span>)
126+
{/if}
125127
{/if}
126-
{/if}
127-
<br />
128-
{/each}
129-
{:else if cost.monthlySummary.series.length == 1}
130-
{@const c = cost.monthlySummary.series[0]}
131-
{c.date.toLocaleString('en-GB', { month: 'long' })}: {euroValueFormatter(
132-
getEstimateForMonth(c.cost, c.date)
133-
)}
134-
{:else}
135-
No cost data available
136-
{/if}
137-
</div>
138-
<div style="height: 200px; overflow: hidden;">
139-
<EChart
140-
options={costTransform(
141-
cost.monthlySummary.series,
142-
getEstimateForMonth(cost.monthlySummary.series[0].cost, cost.monthlySummary.series[0].date)
143-
)}
144-
/>
145-
</div>
128+
<br />
129+
{/each}
130+
{:else if cost.monthlySummary.series.length == 1}
131+
{@const c = cost.monthlySummary.series[0]}
132+
{c.date.toLocaleString('en-GB', { month: 'long' })}: {euroValueFormatter(
133+
getEstimateForMonth(c.cost, c.date)
134+
)}
135+
{:else}
136+
No cost data available
137+
{/if}
138+
</div>
139+
<div style="height: 200px; overflow: hidden;">
140+
<EChart
141+
options={costTransform(
142+
cost.monthlySummary.series,
143+
getEstimateForMonth(
144+
cost.monthlySummary.series[0].cost,
145+
cost.monthlySummary.series[0].date
146+
)
147+
)}
148+
/>
149+
</div>
146150

147-
<a href="/team/{teamSlug}/cost">View team costs</a>
148-
{/if}
151+
<a href="/team/{teamSlug}/cost">View team costs</a>
152+
{/if}
153+
</div>
149154

150155
<style>
151156
.header {
152157
display: flex;
158+
flex-direction: row;
153159
justify-content: space-between;
154160
align-items: center;
155161
}

src/lib/components/RiskStack.svelte

+201
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
<script lang="ts">
2+
import { capitalizeFirstLetter, percentageFormatter } from '$lib/utils/formatters';
3+
import { BodyShort } from '@nais/ds-svelte-community';
4+
5+
type VulnerabilitySummary = {
6+
critical: number;
7+
high: number;
8+
medium: number;
9+
low: number;
10+
unassigned: number;
11+
riskScore?: number;
12+
coverage?: number;
13+
};
14+
15+
interface Props {
16+
summary: VulnerabilitySummary;
17+
}
18+
19+
let { summary }: Props = $props();
20+
21+
const weights = { critical: 10, high: 5, medium: 3, low: 1, unassigned: 5 };
22+
23+
const scores = {
24+
critical: summary.critical * weights.critical,
25+
high: summary.high * weights.high,
26+
medium: summary.medium * weights.medium,
27+
low: summary.low * weights.low,
28+
unassigned: summary.unassigned * weights.unassigned
29+
};
30+
31+
const totalScore = Object.values(scores).reduce((sum, value) => sum + value, 0);
32+
33+
const percentages = {
34+
critical: (scores.critical / totalScore) * 100,
35+
high: (scores.high / totalScore) * 100,
36+
medium: (scores.medium / totalScore) * 100,
37+
low: (scores.low / totalScore) * 100,
38+
unassigned: (scores.unassigned / totalScore) * 100
39+
};
40+
</script>
41+
42+
<div class="stack-container">
43+
<div class="risk-score-label">
44+
{#if summary.riskScore}
45+
<dl>
46+
<dt>Risk score:</dt>
47+
<dd>
48+
<span class={summary['riskScore'] > 100 ? 'red' : 'green'}>{summary['riskScore']}</span>
49+
</dd>
50+
</dl>
51+
{/if}
52+
</div>
53+
<div class="stack">
54+
{#each Object.keys(scores) as level (level)}
55+
{#if scores[level as keyof typeof scores] > 0}
56+
<div
57+
class="segment {level}"
58+
style="height: {percentages[level as keyof typeof percentages]}%;"
59+
></div>
60+
{/if}
61+
{/each}
62+
</div>
63+
<div class="labels">
64+
{#each Object.keys(scores) as level (level)}
65+
{#if scores[level as keyof typeof scores] > 0}
66+
<div class="label-item">
67+
<span class="label-dot {level}"></span>
68+
{capitalizeFirstLetter(level)}: {summary[
69+
level as keyof typeof summary
70+
]}&NonBreakingSpace;(x5)
71+
</div>
72+
{/if}
73+
{/each}
74+
</div>
75+
</div>
76+
<div class="container">
77+
<dl>
78+
{#if summary['coverage']}
79+
<dt>Coverage:</dt>
80+
<dd>
81+
<span class={summary['coverage'] < 100 ? 'red' : 'green'}
82+
>{percentageFormatter(summary['coverage'] ? summary['coverage'] : 0, 0)}</span
83+
>
84+
</dd>
85+
{/if}
86+
</dl>
87+
</div>
88+
89+
<details>
90+
<summary>Risk Score Breakdown</summary>
91+
<BodyShort style="margin-bottom: var(--spacing-layout)">
92+
The stacked chart visualizes the risk score composition based on detected vulnerabilities. Each
93+
segment represents a severity level—Critical, High, Medium, Low, and Unassigned—weighted
94+
according to impact. The total risk score is calculated as: (Critical * 10) + (High * 5) +
95+
(Medium * 3) + (Low * 1) + (Unassigned * 5) Higher sections indicate a greater contribution to
96+
the overall risk. The color intensity reflects severity, helping to quickly assess risk
97+
distribution.
98+
</BodyShort>
99+
</details>
100+
101+
<style>
102+
.stack-container {
103+
display: flex;
104+
align-items: center;
105+
gap: 1rem;
106+
}
107+
108+
.labels {
109+
display: flex;
110+
flex-direction: column;
111+
gap: 0.5rem;
112+
}
113+
114+
.label-item {
115+
display: flex;
116+
align-items: center;
117+
118+
font-size: 0.9rem;
119+
}
120+
121+
.label-dot {
122+
width: 12px;
123+
height: 12px;
124+
border-radius: 50%;
125+
margin-right: 0.5rem;
126+
}
127+
.stack {
128+
display: flex;
129+
flex-direction: column;
130+
width: 64px;
131+
height: 256px;
132+
/*row-gap: 1px;*/
133+
134+
:global(.segment:first-of-type) {
135+
border-top-left-radius: 8px;
136+
border-top-right-radius: 8px;
137+
}
138+
139+
:global(.segment:last-of-type) {
140+
border-bottom-right-radius: 8px;
141+
border-bottom-left-radius: 8px;
142+
}
143+
}
144+
145+
.risk-score-label {
146+
display: flex;
147+
align-items: center;
148+
justify-content: center;
149+
width: 16px;
150+
height: 256px;
151+
font-weight: bold;
152+
transform: rotate(-90deg);
153+
white-space: nowrap;
154+
z-index: 1;
155+
}
156+
157+
.segment {
158+
width: 100%;
159+
transition: height 5s ease-in-out;
160+
}
161+
162+
.critical {
163+
background-color: var(--a-red-200);
164+
}
165+
.high {
166+
background-color: color-mix(in oklab, var(--a-red-200), var(--a-orange-200));
167+
}
168+
.medium {
169+
background-color: var(--a-orange-200);
170+
}
171+
.low {
172+
background-color: var(--a-green-200);
173+
}
174+
.unassigned {
175+
background-color: var(--a-gray-200);
176+
}
177+
.red {
178+
color: var(--a-surface-danger);
179+
}
180+
181+
.green {
182+
color: var(--a-surface-success);
183+
}
184+
185+
dl {
186+
display: grid;
187+
grid-template-columns: 90px auto;
188+
margin-block-start: 0;
189+
margin-block-end: 0;
190+
padding-top: var(--a-spacing-4);
191+
}
192+
193+
dt {
194+
font-weight: bold;
195+
text-align: left;
196+
}
197+
198+
dd {
199+
margin: 0;
200+
}
201+
</style>

0 commit comments

Comments
 (0)