Files
coolify/resources/views/livewire/project/resource/index.blade.php
T
Andras Bacsai bdb367555d feat(ui): unify brand accents and polish shared controls
Standardize highlighted buttons, selected controls, and loading
indicators on coollabs accent colors; improve light-mode log toolbar
styling; enhance small popups with compact/icon modes; and align
navbars, helpers, and related layouts with matching tests.
2026-08-05 22:34:56 +02:00

595 lines
36 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<div>
<x-slot:title>
{{ data_get_str($environment, 'name')->limit(10) }} > Resources | Coolify
</x-slot>
<div x-data="resourceIndex()" class="w-full">
<header class="mb-5 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0">
<h1 class="truncate text-[24px]! leading-7! font-semibold! tracking-tight!">{{ $environment->name }}</h1>
<p class="mt-1 text-[13px] text-neutral-500 dark:text-fg-dim">
<span x-text="`${resources.length} ${resources.length === 1 ? 'resource' : 'resources'}`"></span>
in {{ $project->name }}
</p>
</div>
<div class="flex w-fit shrink-0 items-center gap-2">
@can('update', $project)
<a href="{{ route('project.environment.edit', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid]) }}"
{{ wireNavigate() }}
class="button whitespace-nowrap"
title="Environment settings"
aria-label="Open settings for {{ $environment->name }}">
<x-reicon name="settings" class="size-3.5" />
Settings
</a>
@endcan
@can('createAnyResource')
<a href="{{ route('project.resource.create', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid]) }}"
{{ wireNavigate() }}
class="button whitespace-nowrap button-highlighted">
<x-reicon name="plus" class="size-3.5" />
New resource
</a>
@endcan
</div>
</header>
@if ($environment->isEmpty())
@can('createAnyResource')
<x-empty title="No resources yet"
description="Add an application, database, or service to this environment."
icon-name="layers">
<x-slot:contents>
<a href="{{ route('project.resource.create', ['project_uuid' => $project->uuid, 'environment_uuid' => $environment->uuid]) }}"
{{ wireNavigate() }} class="button">
<x-reicon name="plus" class="size-3.5" />
Add resource
</a>
</x-slot:contents>
</x-empty>
@else
<x-empty title="No resources yet"
description="Add an application, database, or service to this environment."
icon-name="layers" />
@endcan
@else
<div class="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="relative w-full sm:max-w-sm">
<x-reicon name="search"
class="pointer-events-none absolute top-1/2 left-2.5 z-10 size-3.5 -translate-y-1/2 text-neutral-400 dark:text-fg-faint" />
<input x-model.debounce.150ms="search" x-on:input="page = 1" type="search"
placeholder="Search resources"
class="h-8! w-full rounded-lg! border-neutral-200! bg-white! py-0! pr-8! pl-8! text-[12px]! shadow-none! placeholder:text-neutral-400 focus:border-accent! focus:ring-0! dark:border-white/[0.08]! dark:bg-white/[0.035]! dark:text-fg! dark:placeholder:text-fg-faint">
<button x-cloak x-show="search" x-on:click="search = ''; page = 1" type="button"
class="absolute top-1/2 right-2 flex size-5 -translate-y-1/2 items-center justify-center rounded text-neutral-400 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.07] dark:hover:text-fg"
aria-label="Clear search">
<span class="text-sm leading-none">×</span>
</button>
</div>
<div class="flex items-center gap-2">
<div class="relative" x-on:click.outside="filterOpen = false">
<button type="button" class="button max-w-64"
:class="activeFilterCount > 0 && 'button-highlighted'"
x-on:click="filterOpen = !filterOpen" :title="activeFilterCount > 0 ? filterButtonText : 'Filter'">
<svg class="size-3.5 opacity-65" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M4 6h16M7 12h10M10 18h4" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" />
</svg>
<span class="truncate" x-text="activeFilterCount > 0 ? filterButtonText : 'Filter'"></span>
<span x-show="activeFilterCount > 0"
class="shrink-0 rounded-full bg-neutral-100 px-1.5 py-0.5 text-[10px] font-medium text-neutral-500 dark:bg-white/[0.07] dark:text-fg-dim"
x-text="activeFilterCount"></span>
</button>
<div x-cloak x-show="filterOpen" x-transition.origin.top.right
class="absolute top-9 right-0 z-50 flex w-64 flex-col overflow-hidden rounded-lg border border-neutral-200 bg-white shadow-modal dark:border-white/[0.1] dark:bg-raised">
<div class="max-h-80 overflow-y-auto p-1">
<template x-for="group in filterGroups" :key="group.key">
<div x-show="group.options.length > 0">
<div class="px-2 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wide text-neutral-400 dark:text-fg-faint"
x-text="group.label"></div>
<template x-for="option in group.options" :key="`${group.key}-${option.value}`">
<button type="button" class="listbox-option"
x-on:click="toggleFilter(group.key, option.value)">
<span class="min-w-0 flex-1 truncate" x-text="option.label"></span>
<span
class="flex size-4 shrink-0 items-center justify-center rounded-[5px] border"
:class="isFilterSelected(group.key, option.value)
? 'border-coollabs bg-coollabs text-white dark:border-warning dark:bg-warning dark:text-black'
: 'border-neutral-300 bg-white dark:border-white/[0.14] dark:bg-white/[0.045]'">
<svg x-show="isFilterSelected(group.key, option.value)" class="size-3"
viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="m2.25 6.15 2.35 2.3 5.15-5" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</span>
</button>
</template>
</div>
</template>
</div>
<div class="border-t border-neutral-200 bg-white p-1 dark:border-white/10 dark:bg-raised">
<button type="button" class="listbox-option justify-center! text-center!"
x-on:click="clearFilters()">
Clear filters
</button>
</div>
</div>
</div>
<div class="relative" x-on:click.outside="sortOpen = false">
<button type="button" class="button" x-on:click="sortOpen = !sortOpen">
<svg class="size-3.5 opacity-65" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M8 5v14m0 0-3-3m3 3 3-3M16 19V5m0 0-3 3m3-3 3 3"
stroke="currentColor" stroke-width="1.7" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
Sort
</button>
<div x-cloak x-show="sortOpen" x-transition.origin.top.right
class="absolute top-9 right-0 z-50 w-48 rounded-lg border border-neutral-200 bg-white p-1 shadow-modal dark:border-white/[0.1] dark:bg-raised">
<template x-for="option in sortOptions" :key="option.value">
<button type="button"
class="flex h-8 w-full items-center rounded-md px-2 text-left text-[12px] text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-black dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
x-on:click="sortBy = option.value; sortOpen = false; page = 1">
<span class="flex-1" x-text="option.label"></span>
<svg x-show="sortBy === option.value" class="size-3.5 text-warning"
viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="m2.5 6.25 2.1 2.1 4.9-5" stroke="currentColor"
stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</template>
</div>
</div>
<div
class="flex h-8 items-center rounded-lg border border-neutral-200 bg-white p-0.5 dark:border-white/[0.08] dark:bg-white/[0.035]">
<button type="button" x-on:click="setViewMode('table')"
class="flex size-6.5 items-center justify-center rounded-md transition-colors"
:class="viewMode === 'table'
?
'control-selected' :
'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'"
aria-label="Table view" title="Table view">
<x-reicon name="unordered-list" class="size-3.5" />
</button>
<button type="button" x-on:click="setViewMode('grid')"
class="flex size-6.5 items-center justify-center rounded-md transition-colors"
:class="viewMode === 'grid'
?
'control-selected' :
'text-neutral-400 hover:bg-neutral-100 hover:text-black dark:text-fg-faint dark:hover:bg-white/[0.06] dark:hover:text-fg'"
aria-label="Grid view" title="Grid view">
<x-reicon name="grid" class="size-3.5" />
</button>
</div>
</div>
</div>
<div x-show="viewMode === 'table'"
class="overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025]">
<div
class="environment-resource-grid border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 text-[11px] font-medium text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-faint">
<div>Resource</div>
<div>Type</div>
<div>Status</div>
<div class="resource-domain">Domain</div>
<div>Server</div>
<div class="resource-tags">Tags</div>
</div>
<template x-for="item in paginatedResources" :key="item.uuid">
<div
class="environment-resource-grid group min-h-14 items-center border-b border-neutral-200 px-4 py-2.5 transition-colors last:border-b-0 hover:bg-neutral-50 dark:border-white/[0.07] dark:hover:bg-white/[0.025]">
<div class="flex min-w-0 items-center gap-3">
<div
class="flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.035] dark:text-fg-dim">
<template x-if="item.type === 'application'">
<x-reicon name="browser-code" class="size-4" />
</template>
<template x-if="item.type === 'database'">
<x-reicon name="database" class="size-4" />
</template>
<template x-if="item.type === 'service'">
<x-reicon name="layers" class="size-4" />
</template>
</div>
<div class="min-w-0">
<div class="flex min-w-0 items-center gap-1.5">
<a :href="item.hrefLink"
@click="if (item.version === 'v5') { $event.preventDefault(); window.location.assign(item.hrefLink) }"
{{ wireNavigate() }}
class="block truncate text-[13px] font-semibold text-black hover:underline dark:text-fg"
x-text="item.name"></a>
<span x-show="item.version === 'v5'"
class="shrink-0 rounded-md border border-neutral-200 bg-neutral-50 px-1.5 py-0.5 text-[10px] font-medium text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.035] dark:text-fg-faint">V5</span>
</div>
<p class="min-h-4 truncate text-[11px] text-neutral-500 dark:text-fg-faint">
<span x-show="item.description" x-text="item.description"></span>
</p>
</div>
</div>
<div class="truncate text-[12px] text-neutral-600 dark:text-fg-dim"
x-text="item.typeLabel"></div>
<div>
<x-status-badge dynamic>
<span class="size-1.5 shrink-0 rounded-full"
x-bind:class="statusDotClass(item)"></span>
<span class="truncate" x-text="statusLabel(item)"></span>
</x-status-badge>
</div>
<div class="resource-domain min-w-0">
<template x-if="item.fqdn">
<a :href="firstDomain(item.fqdn)" target="_blank"
class="block truncate text-[12px] text-neutral-600 hover:underline dark:text-fg-dim"
x-text="displayDomain(item.fqdn)"></a>
</template>
<span x-show="!item.fqdn" class="text-[12px] text-neutral-400 dark:text-fg-faint">-</span>
</div>
<div class="truncate text-[12px] text-neutral-600 dark:text-fg-dim"
x-text="item.destination?.server?.name || 'Unknown'"></div>
<div class="resource-tags flex min-w-0 items-center gap-1 overflow-hidden">
<template x-for="tag in item.tags.slice(0, 2)" :key="tag.id">
<a :href="`/tags/${tag.name}`"
class="max-w-24 truncate rounded-md border border-neutral-200 bg-neutral-50 px-1.5 py-0.5 text-[10px] text-neutral-500 hover:text-black dark:border-white/[0.08] dark:bg-white/[0.035] dark:text-fg-faint dark:hover:text-fg"
x-text="tag.name"></a>
</template>
<span x-show="item.tags.length > 2"
class="text-[10px] text-neutral-400 dark:text-fg-faint"
x-text="`+${item.tags.length - 2}`"></span>
<span x-show="item.tags.length === 0"
class="text-[12px] text-neutral-400 dark:text-fg-faint">-</span>
</div>
</div>
</template>
<div x-show="filteredResources.length === 0"
class="flex min-h-52 flex-col items-center justify-center px-6 text-center">
<x-reicon name="search" class="mb-3 size-6 text-neutral-300 dark:text-fg-faint" />
<p class="text-[13px] font-medium">No matching resources</p>
<p class="mt-1 text-[12px] text-neutral-500 dark:text-fg-dim">
Try a different search or filter.
</p>
</div>
<footer x-show="totalPages > 1"
class="flex min-h-11 items-center justify-between border-t border-neutral-200 px-4 text-[11px] text-neutral-500 dark:border-white/[0.08] dark:text-fg-faint">
<span
x-text="filteredResources.length === 0 ? '0 resources' : `${rangeStart}-${rangeEnd} of ${filteredResources.length}`"></span>
<div class="flex items-center gap-1">
<button type="button" x-on:click="previousPage" :disabled="page === 1"
class="flex size-7 items-center justify-center rounded-md border border-neutral-200 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black disabled:pointer-events-none disabled:opacity-35 dark:border-white/[0.08] dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
aria-label="Previous page">
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="m15 5-7 7 7 7" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button type="button" x-on:click="nextPage" :disabled="page >= totalPages"
class="flex size-7 items-center justify-center rounded-md border border-neutral-200 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black disabled:pointer-events-none disabled:opacity-35 dark:border-white/[0.08] dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
aria-label="Next page">
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="m9 5 7 7-7 7" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
</footer>
</div>
<div x-cloak x-show="viewMode === 'grid'">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<template x-for="item in paginatedResources" :key="item.uuid">
<article
class="group relative flex min-h-28 flex-col rounded-xl border border-neutral-200 bg-white p-3 shadow-sm transition-all hover:-translate-y-px hover:border-neutral-300 hover:shadow-md dark:border-white/[0.08] dark:bg-white/[0.025] dark:hover:border-white/[0.14]">
<a :href="item.hrefLink"
@click="if (item.version === 'v5') { $event.preventDefault(); window.location.assign(item.hrefLink) }"
{{ wireNavigate() }} class="absolute inset-0 rounded-xl"
:aria-label="`Open ${item.name}`"></a>
<div class="flex items-start gap-3">
<div
class="flex size-8 shrink-0 items-center justify-center rounded-lg border border-neutral-200 bg-neutral-50 text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-fg-dim">
<template x-if="item.type === 'application'">
<x-reicon name="browser-code" class="size-4" />
</template>
<template x-if="item.type === 'database'">
<x-reicon name="database" class="size-4" />
</template>
<template x-if="item.type === 'service'">
<x-reicon name="layers" class="size-4" />
</template>
</div>
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-1.5">
<h2
class="truncate text-[13px]! leading-4! font-semibold! text-black dark:text-fg"
x-text="item.name"></h2>
<span x-show="item.version === 'v5'"
class="shrink-0 rounded-md border border-neutral-200 bg-neutral-50 px-1.5 py-0.5 text-[10px] font-medium text-neutral-500 dark:border-white/[0.08] dark:bg-white/[0.035] dark:text-fg-faint">V5</span>
</div>
<p class="mt-0.5 text-[11px] text-neutral-500 dark:text-fg-faint"
x-text="item.typeLabel"></p>
</div>
<x-status-badge dynamic>
<span class="size-1.5 shrink-0 rounded-full"
x-bind:class="statusDotClass(item)"></span>
<span class="truncate" x-text="statusLabel(item)"></span>
</x-status-badge>
</div>
<div class="mt-auto flex items-center pt-4">
<p class="min-w-0 truncate text-[11px] text-neutral-500 dark:text-fg-dim"
x-text="displayDomain(item.fqdn) || item.description || item.destination?.server?.name || 'No endpoint'">
</p>
</div>
</article>
</template>
</div>
<div x-show="filteredResources.length === 0"
class="flex min-h-52 flex-col items-center justify-center rounded-xl border border-neutral-200 bg-white px-6 text-center dark:border-white/[0.08] dark:bg-white/[0.025]">
<x-reicon name="search" class="mb-3 size-6 text-neutral-300 dark:text-fg-faint" />
<p class="text-[13px] font-medium">No matching resources</p>
<p class="mt-1 text-[12px] text-neutral-500 dark:text-fg-dim">
Try a different search or filter.
</p>
</div>
<footer x-show="totalPages > 1"
class="mt-3 flex min-h-11 items-center justify-between rounded-xl border border-neutral-200 bg-white px-4 text-[11px] text-neutral-500 shadow-sm dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-faint">
<span
x-text="filteredResources.length === 0 ? '0 resources' : `${rangeStart}-${rangeEnd} of ${filteredResources.length}`"></span>
<div class="flex items-center gap-1">
<button type="button" x-on:click="previousPage" :disabled="page === 1"
class="flex size-7 items-center justify-center rounded-md border border-neutral-200 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black disabled:pointer-events-none disabled:opacity-35 dark:border-white/[0.08] dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
aria-label="Previous page">
<svg class="size-3.5" viewBox="0 0 24 24" fill="none"
aria-hidden="true">
<path d="m15 5-7 7 7 7" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<button type="button" x-on:click="nextPage" :disabled="page >= totalPages"
class="flex size-7 items-center justify-center rounded-md border border-neutral-200 text-neutral-500 transition-colors hover:bg-neutral-100 hover:text-black disabled:pointer-events-none disabled:opacity-35 dark:border-white/[0.08] dark:text-fg-dim dark:hover:bg-white/[0.06] dark:hover:text-fg"
aria-label="Next page">
<svg class="size-3.5" viewBox="0 0 24 24" fill="none"
aria-hidden="true">
<path d="m9 5 7 7-7 7" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
</div>
</footer>
</div>
@endif
</div>
</div>
<script>
function resourceIndex() {
return {
search: '',
typeFilters: [],
tagFilters: [],
serverFilters: [],
statusFilters: [],
sortBy: 'name-asc',
viewMode: localStorage.getItem('environment-resource-view') || 'table',
filterOpen: false,
sortOpen: false,
page: 1,
pageSize: 10,
resources: [
...@js($applicationsJs),
...@js($postgresqlsJs),
...@js($redisJs),
...@js($mongodbsJs),
...@js($mysqlsJs),
...@js($mariadbsJs),
...@js($keydbsJs),
...@js($dragonfliesJs),
...@js($clickhousesJs),
...@js($servicesJs),
],
get filterGroups() {
return [{
key: 'typeFilters',
label: 'Resource types',
options: this.uniqueOptions(this.resources.map((item) => ({
value: item.type,
label: item.typeLabel,
}))),
},
{
key: 'tagFilters',
label: 'Tags',
options: this.uniqueOptions(this.resources.flatMap((item) =>
(item.tags || []).map((tag) => ({ value: tag.name, label: tag.name }))
)),
},
{
key: 'serverFilters',
label: 'Servers',
options: this.uniqueOptions(this.resources.map((item) => ({
value: item.destination?.server?.name || 'Unknown',
label: item.destination?.server?.name || 'Unknown',
}))),
},
{
key: 'statusFilters',
label: 'Statuses',
options: this.uniqueOptions(this.resources.map((item) => ({
value: this.statusState(item),
label: this.statusLabel(item),
}))),
},
];
},
get activeFilterCount() {
return this.typeFilters.length + this.tagFilters.length + this.serverFilters.length +
this.statusFilters.length;
},
get filterButtonText() {
const selectedLabels = this.filterGroups.flatMap((group) => group.options
.filter((option) => this[group.key].includes(option.value))
.map((option) => option.label));
if (selectedLabels.length === 0) return 'Filter';
if (selectedLabels.length === 1) return selectedLabels[0];
return `${selectedLabels[0]} +${selectedLabels.length - 1}`;
},
sortOptions: [{
value: 'name-asc',
label: 'Name AZ'
},
{
value: 'name-desc',
label: 'Name ZA'
},
{
value: 'type',
label: 'Resource type'
},
{
value: 'status',
label: 'Status'
},
],
get filteredResources() {
const query = this.search.trim().toLowerCase();
const items = this.resources.filter((item) => {
const matchesType = this.typeFilters.length === 0 || this.typeFilters.includes(item.type);
const matchesTags = this.tagFilters.length === 0 || (item.tags || [])
.some((tag) => this.tagFilters.includes(tag.name));
const serverName = item.destination?.server?.name || 'Unknown';
const matchesServer = this.serverFilters.length === 0 || this.serverFilters.includes(serverName);
const matchesStatus = this.statusFilters.length === 0 || this.statusFilters.includes(this.statusState(item));
const searchable = [
item.name,
item.fqdn,
item.description,
item.typeLabel,
item.status,
item.destination?.server?.name,
...(item.tags || []).map((tag) => tag.name),
].filter(Boolean).join(' ').toLowerCase();
return matchesType && matchesTags && matchesServer && matchesStatus &&
(!query || searchable.includes(query));
});
return items.sort((first, second) => {
if (this.sortBy === 'name-desc') {
return second.name.localeCompare(first.name);
}
if (this.sortBy === 'type') {
return first.typeLabel.localeCompare(second.typeLabel) ||
first.name.localeCompare(second.name);
}
if (this.sortBy === 'status') {
return this.statusLabel(first).localeCompare(this.statusLabel(second)) ||
first.name.localeCompare(second.name);
}
return first.name.localeCompare(second.name);
});
},
uniqueOptions(options) {
return [...new Map(options
.filter((option) => option.value)
.map((option) => [option.value, option])).values()]
.sort((first, second) => first.label.localeCompare(second.label));
},
isFilterSelected(group, value) {
return this[group].includes(value);
},
toggleFilter(group, value) {
this[group] = this[group].includes(value)
? this[group].filter((selected) => selected !== value)
: [...this[group], value];
this.page = 1;
},
clearFilters() {
this.typeFilters = [];
this.tagFilters = [];
this.serverFilters = [];
this.statusFilters = [];
this.page = 1;
},
get totalPages() {
return Math.max(1, Math.ceil(this.filteredResources.length / this.pageSize));
},
get paginatedResources() {
if (this.page > this.totalPages) {
this.page = this.totalPages;
}
const start = (this.page - 1) * this.pageSize;
return this.filteredResources.slice(start, start + this.pageSize);
},
get rangeStart() {
return this.filteredResources.length === 0 ? 0 : ((this.page - 1) * this.pageSize) + 1;
},
get rangeEnd() {
return Math.min(this.page * this.pageSize, this.filteredResources.length);
},
previousPage() {
this.page = Math.max(1, this.page - 1);
},
nextPage() {
this.page = Math.min(this.totalPages, this.page + 1);
},
setViewMode(mode) {
this.viewMode = mode;
this.page = 1;
localStorage.setItem('environment-resource-view', mode);
},
statusState(item) {
return String(item.status || 'unknown').split(':')[0].toLowerCase();
},
statusLabel(item) {
const state = this.statusState(item);
return state.charAt(0).toUpperCase() + state.slice(1);
},
statusTone(item) {
const state = this.statusState(item);
if (state === 'running') {
return 'success';
}
if (['starting', 'restarting', 'degraded'].includes(state)) {
return 'warning';
}
if (['exited', 'stopped', 'failed'].includes(state)) {
return 'error';
}
return 'neutral';
},
statusDotClass(item) {
return {
success: 'bg-emerald-500',
warning: 'bg-warning',
error: 'bg-red-500',
neutral: 'bg-neutral-400 dark:bg-neutral-500',
} [this.statusTone(item)];
},
firstDomain(fqdn) {
return String(fqdn).split(',')[0].trim();
},
displayDomain(fqdn) {
if (!fqdn) {
return '';
}
return this.firstDomain(fqdn).replace(/^https?:\/\//, '');
},
};
}
</script>