Files
nadir-webui/src/routes/dashboard/[machineId]/services/+page.svelte
T
2026-06-25 23:11:32 +02:00

296 lines
9.1 KiB
Svelte

<script lang="ts">
import ArrowDownIcon from '@lucide/svelte/icons/arrow-down';
import ArrowUpIcon from '@lucide/svelte/icons/arrow-up';
import { resolve } from '$app/paths';
import { page as pageState } from '$app/state';
import DataTable from '$lib/components/dashboard/data-table.svelte';
import PageMeta from '$lib/components/seo/page-meta.svelte';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Label } from '$lib/components/ui/label';
import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import { listServices } from '$lib/remotes/services.remote';
type ServiceUnit = {
active: string;
description: string;
load: string;
sub: string;
unit: string;
};
const machineId = $derived(pageState.params.machineId!);
const servicesResource = $derived(listServices(machineId));
const services = $derived((servicesResource.current?.services ?? []) as ServiceUnit[]);
let search = $state('');
let searchTimer: ReturnType<typeof setTimeout> | undefined;
let debouncedSearch = $state('');
let sortDir = $state<'asc' | 'desc'>('asc');
let filterActive = $state({
active: true,
failed: true,
inactive: true,
other: true
});
let filterLoad = $state({
error: true,
loaded: true,
masked: true,
notFound: true
});
let filterSub = $state({
dead: true,
exited: true,
other: true,
running: true
});
const activeFilterCount = $derived(
(Object.values(filterActive).some((v) => !v) ? 1 : 0) +
(Object.values(filterLoad).some((v) => !v) ? 1 : 0) +
(Object.values(filterSub).some((v) => !v) ? 1 : 0)
);
function resetFilters() {
filterActive = { active: true, failed: true, inactive: true, other: true };
filterLoad = { error: true, loaded: true, masked: true, notFound: true };
filterSub = { dead: true, exited: true, other: true, running: true };
}
function onSearchInput() {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
debouncedSearch = search;
}, 200);
}
let page = $state(1);
function toggleSort() {
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
}
const filtered = $derived.by(() => {
const q = debouncedSearch.trim().toLowerCase();
let list = [...services];
if (q) {
list = list.filter(
(s) => s.unit.toLowerCase().includes(q) || s.description.toLowerCase().includes(q)
);
}
list = list.filter((s) => {
const activeCat =
s.active === 'active'
? 'active'
: s.active === 'inactive'
? 'inactive'
: s.active === 'failed'
? 'failed'
: 'other';
return filterActive[activeCat];
});
list = list.filter((s) => {
const loadCat =
s.load === 'loaded'
? 'loaded'
: s.load === 'masked'
? 'masked'
: s.load === 'not-found'
? 'notFound'
: s.load === 'error'
? 'error'
: 'loaded';
return filterLoad[loadCat as keyof typeof filterLoad] ?? true;
});
list = list.filter((s) => {
const subCat =
s.sub === 'running'
? 'running'
: s.sub === 'exited'
? 'exited'
: s.sub === 'dead'
? 'dead'
: 'other';
return filterSub[subCat as keyof typeof filterSub] ?? true;
});
list.sort((a, b) => {
const comp = a.unit.localeCompare(b.unit);
return sortDir === 'asc' ? comp : -comp;
});
return list;
});
</script>
<PageMeta title={m.seo_title_services()} description={m.seo_desc_services()} />
<DataTable
title={m.services_title()}
description={m.services_description()}
searchPlaceholder={m.services_search_placeholder()}
emptyMessage={m.services_no_services()}
items={filtered}
loading={servicesResource.loading}
pageSizeKey="services.pageSize"
defaultPageSize={25}
bind:search
onsearchinput={onSearchInput}
onrefresh={() => servicesResource.refresh()}
{activeFilterCount}
bind:page
i18n={{
display: m.users_filter_display(),
filterTitle: m.services_filter_title(),
next: () => m.users_next(),
pageOf: (p) => m.users_page_of(p),
previous: () => m.users_prev(),
rowsPerPage: m.users_rows_per_page()
}}
>
{#snippet filterContent()}
<div class="flex items-center justify-between border-b p-3">
<h3 class="text-sm font-semibold">{m.services_filter_title()}</h3>
{#if activeFilterCount > 0}
<Button variant="ghost" size="sm" class="h-auto p-1 text-xs" onclick={resetFilters}>
{m.cancel()}
</Button>
{/if}
</div>
<div class="flex flex-col gap-4 p-4">
<div class="flex flex-col gap-2">
<Label class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{m.services_active_filter()}
</Label>
<div class="grid grid-cols-2 gap-2">
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterActive.active} />
{m.services_filter_active()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterActive.inactive} />
{m.services_filter_inactive()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterActive.failed} />
{m.services_filter_failed()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterActive.other} />
{m.services_filter_other()}
</Label>
</div>
</div>
<div class="flex flex-col gap-2 border-t pt-3">
<Label class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{m.services_load_filter()}
</Label>
<div class="grid grid-cols-2 gap-2">
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterLoad.loaded} />
{m.services_filter_loaded()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterLoad.masked} />
{m.services_filter_masked()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterLoad.notFound} />
{m.services_filter_not_found()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterLoad.error} />
{m.services_filter_error()}
</Label>
</div>
</div>
<div class="flex flex-col gap-2 border-t pt-3">
<Label class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
{m.services_sub_filter()}
</Label>
<div class="grid grid-cols-2 gap-2">
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterSub.running} />
{m.services_filter_running()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterSub.exited} />
{m.services_filter_exited()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterSub.dead} />
{m.services_filter_dead()}
</Label>
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
<Checkbox bind:checked={filterSub.other} />
{m.services_filter_other()}
</Label>
</div>
</div>
</div>
{/snippet}
{#snippet columns()}
<Table.Head>
<button
type="button"
class="hover:text-foreground inline-flex items-center gap-1"
onclick={toggleSort}
>
{m.services_col_unit()}
{#if sortDir === 'asc'}
<ArrowUpIcon class="size-3" />
{:else}
<ArrowDownIcon class="size-3" />
{/if}
</button>
</Table.Head>
<Table.Head class="w-24">{m.services_col_active()}</Table.Head>
<Table.Head class="w-24">{m.services_col_sub()}</Table.Head>
<Table.Head class="w-24">{m.services_col_load()}</Table.Head>
<Table.Head>{m.services_col_description()}</Table.Head>
{/snippet}
{#snippet row(s: ServiceUnit)}
<Table.Cell class="font-medium font-mono text-xs max-w-[20rem]">
<a
href={resolve('/dashboard/[machineId]/services/[name]', {
machineId,
name: s.unit
})}
class="block truncate text-primary hover:underline"
title={s.unit}
>
{s.unit}
</a>
</Table.Cell>
<Table.Cell>
{#if s.active === 'active'}
<Badge
class="bg-emerald-500/15 text-emerald-700 dark:text-emerald-400 hover:bg-emerald-500/20 border-0 capitalize"
>
{s.active}
</Badge>
{:else if s.active === 'inactive'}
<Badge
class="bg-zinc-500/15 text-zinc-700 dark:text-zinc-400 hover:bg-zinc-500/20 border-0 capitalize"
>
{s.active}
</Badge>
{:else}
<Badge
class="bg-rose-500/15 text-rose-700 dark:text-rose-400 hover:bg-rose-500/20 border-0 capitalize"
>
{s.active}
</Badge>
{/if}
</Table.Cell>
<Table.Cell class="font-mono text-xs text-muted-foreground capitalize">{s.sub}</Table.Cell>
<Table.Cell class="font-mono text-xs text-muted-foreground capitalize">{s.load}</Table.Cell>
<Table.Cell class="text-muted-foreground text-xs max-w-[20rem] w-full">
<span class="block truncate" title={s.description}>{s.description}</span>
</Table.Cell>
{/snippet}
</DataTable>