130 lines
4.4 KiB
Svelte
130 lines
4.4 KiB
Svelte
<script lang="ts">
|
|
import { page } from '$app/stores';
|
|
import type { UserRole } from '$lib/auth.js';
|
|
import { navigationItemActive, visibleNavigation } from '$lib/navigation.js';
|
|
import { serverHealth } from '$ui/server-health.js';
|
|
import { fleet, fleetServers } from '$ui/stores/fleet.js';
|
|
import { uiNow } from '$ui/stores/servers.js';
|
|
|
|
let { role, onNavigate }: { role: UserRole; onNavigate?: () => void } = $props();
|
|
const id = $props.id();
|
|
const groups = $derived(visibleNavigation(role));
|
|
const attention = $derived.by(() => {
|
|
if (!$fleet.receivedAt) return null;
|
|
const statuses = $fleetServers.map((server) => ({
|
|
server,
|
|
state: serverHealth(server, $uiNow).state
|
|
}));
|
|
const servers = statuses.filter(
|
|
({ server, state }) =>
|
|
!['online', 'starting', 'provisioning'].includes(state) ||
|
|
$fleet.operations.some(
|
|
(operation) =>
|
|
operation.server === server.name && operation.state === 'attention'
|
|
)
|
|
).length;
|
|
const hosts =
|
|
$fleet.hostAttention ??
|
|
new Set(
|
|
statuses
|
|
.filter(({ server, state }) => server.host && state === 'host-offline')
|
|
.map(({ server }) => server.host!.id)
|
|
).size;
|
|
const maintenance = $fleet.operations.filter(
|
|
(operation) => operation.kind === 'maintenance' && operation.state === 'attention'
|
|
).length;
|
|
return { '/': servers, '/hosts': hosts, '/maintenance': maintenance } as Record<
|
|
string,
|
|
number
|
|
>;
|
|
});
|
|
</script>
|
|
|
|
<nav aria-label="Main navigation">
|
|
{#each groups as group (group.id)}
|
|
<section aria-labelledby={`${id}-${group.id}`}>
|
|
<h2 id={`${id}-${group.id}`}>{group.label}</h2>
|
|
<ul>
|
|
{#each group.items as item (item.href)}
|
|
<li>
|
|
<a
|
|
href={item.href}
|
|
aria-current={navigationItemActive(item, $page.url.pathname)
|
|
? 'page'
|
|
: undefined}
|
|
aria-label={attention && attention[item.href] > 0
|
|
? `${item.label}, ${attention[item.href]} ${attention[item.href] === 1 ? 'needs' : 'need'} attention`
|
|
: undefined}
|
|
onclick={() => onNavigate?.()}
|
|
>
|
|
<span>{item.label}</span>
|
|
{#if attention && attention[item.href] > 0}<span
|
|
class="attention"
|
|
aria-hidden="true">{attention[item.href]}</span
|
|
>{/if}
|
|
</a>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
</section>
|
|
{/each}
|
|
</nav>
|
|
|
|
<style>
|
|
nav {
|
|
display: grid;
|
|
gap: var(--space-4);
|
|
min-width: 0;
|
|
}
|
|
h2 {
|
|
margin: 0 0 var(--space-1);
|
|
padding: 0 var(--space-3);
|
|
color: var(--text-tertiary);
|
|
font-size: var(--text-xs);
|
|
font-weight: var(--weight-semibold);
|
|
letter-spacing: var(--tracking-label);
|
|
text-transform: uppercase;
|
|
}
|
|
ul {
|
|
list-style: none;
|
|
padding: 0;
|
|
margin: 0;
|
|
}
|
|
a {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: var(--space-2);
|
|
min-height: 32px;
|
|
padding: 5px var(--space-3);
|
|
border-radius: var(--radius-sm);
|
|
color: var(--text-secondary);
|
|
font-size: var(--text-sm);
|
|
text-decoration: none;
|
|
}
|
|
a:hover {
|
|
color: var(--text-primary);
|
|
background: var(--surface-raised);
|
|
}
|
|
a[aria-current] {
|
|
color: var(--text-primary);
|
|
background: var(--surface-raised);
|
|
box-shadow: inset 2px 0 var(--accent-primary);
|
|
font-weight: var(--weight-semibold);
|
|
}
|
|
a:focus-visible {
|
|
outline-offset: -2px;
|
|
}
|
|
.attention {
|
|
color: var(--state-warning);
|
|
font-variant-numeric: tabular-nums;
|
|
font-size: var(--text-xs);
|
|
font-weight: var(--weight-semibold);
|
|
}
|
|
@media (max-width: 960px) {
|
|
a {
|
|
min-height: 44px;
|
|
}
|
|
}
|
|
</style>
|