296 lines
9.1 KiB
Svelte
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>
|