Enhance service detail page with type-specific sections and provisioning info

Add service-type-specific detail cards (VPS, Dedicated, Game, Web Hosting),
provisioning info accessor that filters sensitive credentials, and improved
sidebar with service overview and quick actions.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Claude Dev
2026-02-09 20:30:12 -05:00
parent f87de9e2a4
commit c82ee91b9a
3 changed files with 749 additions and 40 deletions

View File

@@ -33,6 +33,24 @@ class Service extends Model
'auto_renew', 'auto_renew',
]; ];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [
'credentials',
];
/**
* The accessors to append to the model's array form.
*
* @var list<string>
*/
protected $appends = [
'provisioning_info',
];
protected function casts(): array protected function casts(): array
{ {
return [ return [
@@ -73,4 +91,37 @@ class Service extends Model
{ {
return $this->status === 'active'; return $this->status === 'active';
} }
/**
* Get safe provisioning info excluding sensitive fields like passwords and keys.
*
* @return array<string, mixed>|null
*/
public function getProvisioningInfoAttribute(): ?array
{
$credentials = $this->credentials;
if (! is_array($credentials) || empty($credentials)) {
return null;
}
$sensitiveKeys = [
'password',
'secret',
'token',
'api_key',
'api_secret',
'private_key',
'ssh_key',
'ssh_password',
'root_password',
'admin_password',
'database_password',
'ftp_password',
];
return collect($credentials)
->reject(fn (mixed $value, string $key): bool => in_array(strtolower($key), $sensitiveKeys, true))
->toArray();
}
} }

View File

@@ -34,6 +34,10 @@ const controlPanelUrl = computed<string | null>(() => {
const isSuspended = computed<boolean>(() => props.service.status === 'suspended') const isSuspended = computed<boolean>(() => props.service.status === 'suspended')
const isTerminated = computed<boolean>(() => props.service.status === 'terminated') const isTerminated = computed<boolean>(() => props.service.status === 'terminated')
const isVps = computed<boolean>(() => props.service.service_type === 'vps')
const isDedicated = computed<boolean>(() => props.service.service_type === 'dedicated')
const isGame = computed<boolean>(() => props.service.service_type === 'game-server')
const isWebHosting = computed<boolean>(() => props.service.service_type === 'web-hosting')
const platformLabel = computed<string>(() => { const platformLabel = computed<string>(() => {
const labels: Record<string, string> = { const labels: Record<string, string> = {
@@ -44,23 +48,79 @@ const platformLabel = computed<string>(() => {
} }
return labels[props.service.platform] ?? props.service.platform return labels[props.service.platform] ?? props.service.platform
}) })
const serviceTypeLabel = computed<string>(() => {
const labels: Record<string, string> = {
vps: 'VPS',
dedicated: 'Dedicated Server',
'game-server': 'Game Server',
'web-hosting': 'Web Hosting',
}
return labels[props.service.service_type] ?? props.service.service_type
})
const serviceTypeIcon = computed<string>(() => {
const icons: Record<string, string> = {
vps: 'tabler-server',
dedicated: 'tabler-server-2',
'game-server': 'tabler-device-gamepad-2',
'web-hosting': 'tabler-world-www',
}
return icons[props.service.service_type] ?? 'tabler-server'
})
const provisioningEntries = computed<Array<{ key: string; value: string }>>(() => {
const info = props.service.provisioning_info
if (!info || typeof info !== 'object') return []
return Object.entries(info).map(([key, value]) => ({
key: key.replace(/_/g, ' '),
value: String(value),
}))
})
const connectionString = computed<string | null>(() => {
const info = props.service.provisioning_info
if (!info) return null
if (isGame.value) {
const port = info.port ?? info.game_port
if (props.service.ipv4_address && port) {
return `${props.service.ipv4_address}:${port}`
}
}
return null
})
</script> </script>
<template> <template>
<div> <div>
<!-- Breadcrumb -->
<div class="mb-4"> <div class="mb-4">
<Link <VBreadcrumbs
href="/services" :items="[
class="text-primary text-body-2 text-decoration-none" { title: 'Services', to: '/services', disabled: false },
> { title: service.hostname || service.domain || `Service #${service.id}`, disabled: true },
&larr; Back to Services ]"
</Link> class="pa-0"
/>
</div> </div>
<!-- Service Header --> <!-- Service Header -->
<div class="d-flex align-center justify-space-between mb-6"> <div class="d-flex flex-wrap align-center justify-space-between mb-6 ga-4">
<div> <div>
<div class="d-flex align-center ga-3"> <div class="d-flex flex-wrap align-center ga-3">
<VAvatar
:color="resolveServiceTypeColor(service.service_type)"
variant="tonal"
size="42"
>
<VIcon
:icon="serviceTypeIcon"
size="24"
/>
</VAvatar>
<div class="text-h4 font-weight-bold"> <div class="text-h4 font-weight-bold">
{{ service.hostname || service.domain || `Service #${service.id}` }} {{ service.hostname || service.domain || `Service #${service.id}` }}
</div> </div>
@@ -74,13 +134,15 @@ const platformLabel = computed<string>(() => {
<VChip <VChip
:color="resolveServiceTypeColor(service.service_type)" :color="resolveServiceTypeColor(service.service_type)"
size="small" size="small"
class="text-capitalize"
> >
{{ service.service_type }} {{ serviceTypeLabel }}
</VChip> </VChip>
</div> </div>
<div class="text-body-2 text-medium-emphasis mt-1"> <div class="text-body-2 text-medium-emphasis mt-1">
Managed by {{ platformLabel }} Managed by {{ platformLabel }}
<span v-if="service.platform_service_id">
&middot; ID: {{ service.platform_service_id }}
</span>
</div> </div>
</div> </div>
<div class="d-flex ga-3"> <div class="d-flex ga-3">
@@ -140,17 +202,26 @@ const platformLabel = computed<string>(() => {
</VAlert> </VAlert>
<VRow> <VRow>
<!-- Service Details --> <!-- Service Details Column -->
<VCol <VCol
cols="12" cols="12"
lg="8" lg="8"
> >
<!-- Plan & Pricing --> <!-- Plan & Pricing -->
<VCard class="mb-6"> <VCard class="mb-6">
<VCardTitle>Plan Details</VCardTitle> <VCardTitle>
<VIcon
icon="tabler-package"
class="me-2"
/>
Plan Details
</VCardTitle>
<VCardText> <VCardText>
<VRow> <VRow>
<VCol cols="6"> <VCol
cols="12"
sm="6"
>
<div class="text-body-2 text-medium-emphasis"> <div class="text-body-2 text-medium-emphasis">
Plan Plan
</div> </div>
@@ -158,7 +229,10 @@ const platformLabel = computed<string>(() => {
{{ service.plan?.name || '--' }} {{ service.plan?.name || '--' }}
</div> </div>
</VCol> </VCol>
<VCol cols="6"> <VCol
cols="12"
sm="6"
>
<div class="text-body-2 text-medium-emphasis"> <div class="text-body-2 text-medium-emphasis">
Price Price
</div> </div>
@@ -166,15 +240,21 @@ const platformLabel = computed<string>(() => {
{{ service.plan ? formatPrice(service.plan.price, service.plan.billing_cycle) : '--' }} {{ service.plan ? formatPrice(service.plan.price, service.plan.billing_cycle) : '--' }}
</div> </div>
</VCol> </VCol>
<VCol cols="6"> <VCol
cols="12"
sm="6"
>
<div class="text-body-2 text-medium-emphasis"> <div class="text-body-2 text-medium-emphasis">
Service Type Service Type
</div> </div>
<div class="text-body-1 text-capitalize mt-1"> <div class="text-body-1 text-capitalize mt-1">
{{ service.service_type }} {{ serviceTypeLabel }}
</div> </div>
</VCol> </VCol>
<VCol cols="6"> <VCol
cols="12"
sm="6"
>
<div class="text-body-2 text-medium-emphasis"> <div class="text-body-2 text-medium-emphasis">
Platform Platform
</div> </div>
@@ -182,7 +262,10 @@ const platformLabel = computed<string>(() => {
{{ platformLabel }} {{ platformLabel }}
</div> </div>
</VCol> </VCol>
<VCol cols="6"> <VCol
cols="12"
sm="6"
>
<div class="text-body-2 text-medium-emphasis"> <div class="text-body-2 text-medium-emphasis">
Billing Cycle Billing Cycle
</div> </div>
@@ -190,7 +273,10 @@ const platformLabel = computed<string>(() => {
{{ service.plan?.billing_cycle || '--' }} {{ service.plan?.billing_cycle || '--' }}
</div> </div>
</VCol> </VCol>
<VCol cols="6"> <VCol
cols="12"
sm="6"
>
<div class="text-body-2 text-medium-emphasis"> <div class="text-body-2 text-medium-emphasis">
Auto Renew Auto Renew
</div> </div>
@@ -235,12 +321,24 @@ const platformLabel = computed<string>(() => {
</VCardText> </VCardText>
</VCard> </VCard>
<!-- Network Information --> <!-- VPS Server Details -->
<VCard class="mb-6"> <VCard
<VCardTitle>Network Information</VCardTitle> v-if="isVps"
class="mb-6"
>
<VCardTitle>
<VIcon
icon="tabler-server"
class="me-2"
/>
VPS Server Details
</VCardTitle>
<VCardText> <VCardText>
<VRow> <VRow>
<VCol cols="6"> <VCol
cols="12"
sm="6"
>
<div class="text-body-2 text-medium-emphasis"> <div class="text-body-2 text-medium-emphasis">
IPv4 Address IPv4 Address
</div> </div>
@@ -252,19 +350,10 @@ const platformLabel = computed<string>(() => {
>Not assigned</span> >Not assigned</span>
</div> </div>
</VCol> </VCol>
<VCol cols="6"> <VCol
<div class="text-body-2 text-medium-emphasis"> cols="12"
IPv6 Address sm="6"
</div> >
<div class="text-body-1 mt-1">
<code v-if="service.ipv6_address">{{ service.ipv6_address }}</code>
<span
v-else
class="text-medium-emphasis"
>Not assigned</span>
</div>
</VCol>
<VCol cols="6">
<div class="text-body-2 text-medium-emphasis"> <div class="text-body-2 text-medium-emphasis">
Hostname Hostname
</div> </div>
@@ -276,7 +365,418 @@ const platformLabel = computed<string>(() => {
>Not set</span> >Not set</span>
</div> </div>
</VCol> </VCol>
<VCol cols="6"> <VCol
v-if="service.ipv6_address"
cols="12"
sm="6"
>
<div class="text-body-2 text-medium-emphasis">
IPv6 Address
</div>
<div class="text-body-1 mt-1">
<code>{{ service.ipv6_address }}</code>
</div>
</VCol>
<VCol
cols="12"
sm="6"
>
<div class="text-body-2 text-medium-emphasis">
Control Panel
</div>
<div class="text-body-1 mt-1">
<a
v-if="controlPanelUrl"
:href="controlPanelUrl"
target="_blank"
rel="noopener noreferrer"
class="text-primary"
>
VirtFusion Panel
<VIcon
icon="tabler-external-link"
size="14"
/>
</a>
<span
v-else
class="text-medium-emphasis"
>Not available</span>
</div>
</VCol>
</VRow>
</VCardText>
</VCard>
<!-- Dedicated Server Details -->
<VCard
v-if="isDedicated"
class="mb-6"
>
<VCardTitle>
<VIcon
icon="tabler-server-2"
class="me-2"
/>
Dedicated Server Details
</VCardTitle>
<VCardText>
<VRow>
<VCol
cols="12"
sm="6"
>
<div class="text-body-2 text-medium-emphasis">
Primary IPv4 Address
</div>
<div class="text-body-1 mt-1">
<code v-if="service.ipv4_address">{{ service.ipv4_address }}</code>
<span
v-else
class="text-medium-emphasis"
>Not assigned</span>
</div>
</VCol>
<VCol
cols="12"
sm="6"
>
<div class="text-body-2 text-medium-emphasis">
Hostname
</div>
<div class="text-body-1 mt-1">
<code v-if="service.hostname">{{ service.hostname }}</code>
<span
v-else
class="text-medium-emphasis"
>Not set</span>
</div>
</VCol>
<VCol
v-if="service.ipv6_address"
cols="12"
sm="6"
>
<div class="text-body-2 text-medium-emphasis">
IPv6 Address
</div>
<div class="text-body-1 mt-1">
<code>{{ service.ipv6_address }}</code>
</div>
</VCol>
<VCol
cols="12"
sm="6"
>
<div class="text-body-2 text-medium-emphasis">
Access Information
</div>
<div class="text-body-1 mt-1">
<span v-if="service.provisioning_info?.access_method">
{{ service.provisioning_info.access_method }}
</span>
<span v-else-if="service.ipv4_address">
SSH via {{ service.ipv4_address }}
</span>
<span
v-else
class="text-medium-emphasis"
>Not available</span>
</div>
</VCol>
<VCol
cols="12"
sm="6"
>
<div class="text-body-2 text-medium-emphasis">
Management Panel
</div>
<div class="text-body-1 mt-1">
<a
v-if="controlPanelUrl"
:href="controlPanelUrl"
target="_blank"
rel="noopener noreferrer"
class="text-primary"
>
SynergyCP Panel
<VIcon
icon="tabler-external-link"
size="14"
/>
</a>
<span
v-else
class="text-medium-emphasis"
>Not available</span>
</div>
</VCol>
</VRow>
</VCardText>
</VCard>
<!-- Game Server Details -->
<VCard
v-if="isGame"
class="mb-6"
>
<VCardTitle>
<VIcon
icon="tabler-device-gamepad-2"
class="me-2"
/>
Game Server Details
</VCardTitle>
<VCardText>
<VRow>
<VCol
cols="12"
sm="6"
>
<div class="text-body-2 text-medium-emphasis">
Connection Address
</div>
<div class="text-body-1 mt-1">
<code v-if="connectionString">{{ connectionString }}</code>
<code v-else-if="service.ipv4_address">{{ service.ipv4_address }}</code>
<span
v-else
class="text-medium-emphasis"
>Not assigned</span>
</div>
</VCol>
<VCol
cols="12"
sm="6"
>
<div class="text-body-2 text-medium-emphasis">
Hostname
</div>
<div class="text-body-1 mt-1">
<code v-if="service.hostname">{{ service.hostname }}</code>
<span
v-else
class="text-medium-emphasis"
>Not set</span>
</div>
</VCol>
<VCol
v-if="service.provisioning_info?.game_type"
cols="12"
sm="6"
>
<div class="text-body-2 text-medium-emphasis">
Game Type
</div>
<div class="text-body-1 text-capitalize mt-1">
{{ service.provisioning_info.game_type }}
</div>
</VCol>
<VCol
v-if="service.provisioning_info?.slots || service.provisioning_info?.max_players"
cols="12"
sm="6"
>
<div class="text-body-2 text-medium-emphasis">
Player Slots
</div>
<div class="text-body-1 mt-1">
{{ service.provisioning_info.slots || service.provisioning_info.max_players }}
</div>
</VCol>
<VCol
cols="12"
sm="6"
>
<div class="text-body-2 text-medium-emphasis">
Game Panel
</div>
<div class="text-body-1 mt-1">
<a
v-if="controlPanelUrl"
:href="controlPanelUrl"
target="_blank"
rel="noopener noreferrer"
class="text-primary"
>
Pterodactyl Panel
<VIcon
icon="tabler-external-link"
size="14"
/>
</a>
<span
v-else
class="text-medium-emphasis"
>Not available</span>
</div>
</VCol>
</VRow>
</VCardText>
</VCard>
<!-- Web Hosting Details -->
<VCard
v-if="isWebHosting"
class="mb-6"
>
<VCardTitle>
<VIcon
icon="tabler-world-www"
class="me-2"
/>
Web Hosting Account Details
</VCardTitle>
<VCardText>
<VRow>
<VCol
cols="12"
sm="6"
>
<div class="text-body-2 text-medium-emphasis">
Domain
</div>
<div class="text-body-1 mt-1">
<code v-if="service.domain">{{ service.domain }}</code>
<span
v-else
class="text-medium-emphasis"
>Not set</span>
</div>
</VCol>
<VCol
v-if="service.provisioning_info?.username"
cols="12"
sm="6"
>
<div class="text-body-2 text-medium-emphasis">
Username
</div>
<div class="text-body-1 mt-1">
<code>{{ service.provisioning_info.username }}</code>
</div>
</VCol>
<VCol
cols="12"
sm="6"
>
<div class="text-body-2 text-medium-emphasis">
Server IP
</div>
<div class="text-body-1 mt-1">
<code v-if="service.ipv4_address">{{ service.ipv4_address }}</code>
<span
v-else
class="text-medium-emphasis"
>Not assigned</span>
</div>
</VCol>
<VCol
v-if="service.provisioning_info?.nameservers"
cols="12"
sm="6"
>
<div class="text-body-2 text-medium-emphasis">
Nameservers
</div>
<div class="text-body-1 mt-1">
<code>{{ service.provisioning_info.nameservers }}</code>
</div>
</VCol>
<VCol
cols="12"
sm="6"
>
<div class="text-body-2 text-medium-emphasis">
Hosting Panel
</div>
<div class="text-body-1 mt-1">
<a
v-if="controlPanelUrl"
:href="controlPanelUrl"
target="_blank"
rel="noopener noreferrer"
class="text-primary"
>
Enhance Panel
<VIcon
icon="tabler-external-link"
size="14"
/>
</a>
<span
v-else
class="text-medium-emphasis"
>Not available</span>
</div>
</VCol>
</VRow>
</VCardText>
</VCard>
<!-- Generic Network Information (shown when no type-specific card) -->
<VCard
v-if="!isVps && !isDedicated && !isGame && !isWebHosting"
class="mb-6"
>
<VCardTitle>
<VIcon
icon="tabler-network"
class="me-2"
/>
Network Information
</VCardTitle>
<VCardText>
<VRow>
<VCol
cols="12"
sm="6"
>
<div class="text-body-2 text-medium-emphasis">
IPv4 Address
</div>
<div class="text-body-1 mt-1">
<code v-if="service.ipv4_address">{{ service.ipv4_address }}</code>
<span
v-else
class="text-medium-emphasis"
>Not assigned</span>
</div>
</VCol>
<VCol
cols="12"
sm="6"
>
<div class="text-body-2 text-medium-emphasis">
IPv6 Address
</div>
<div class="text-body-1 mt-1">
<code v-if="service.ipv6_address">{{ service.ipv6_address }}</code>
<span
v-else
class="text-medium-emphasis"
>Not assigned</span>
</div>
</VCol>
<VCol
cols="12"
sm="6"
>
<div class="text-body-2 text-medium-emphasis">
Hostname
</div>
<div class="text-body-1 mt-1">
<code v-if="service.hostname">{{ service.hostname }}</code>
<span
v-else
class="text-medium-emphasis"
>Not set</span>
</div>
</VCol>
<VCol
cols="12"
sm="6"
>
<div class="text-body-2 text-medium-emphasis"> <div class="text-body-2 text-medium-emphasis">
Domain Domain
</div> </div>
@@ -291,6 +791,48 @@ const platformLabel = computed<string>(() => {
</VRow> </VRow>
</VCardText> </VCardText>
</VCard> </VCard>
<!-- Provisioning Information -->
<VCard
v-if="provisioningEntries.length > 0"
class="mb-6"
>
<VCardTitle>
<VIcon
icon="tabler-settings"
class="me-2"
/>
Provisioning Information
</VCardTitle>
<VCardText>
<VTable
density="comfortable"
hover
>
<thead>
<tr>
<th class="text-capitalize">
Property
</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr
v-for="entry in provisioningEntries"
:key="entry.key"
>
<td class="text-capitalize font-weight-medium">
{{ entry.key }}
</td>
<td>
<code>{{ entry.value }}</code>
</td>
</tr>
</tbody>
</VTable>
</VCardText>
</VCard>
</VCol> </VCol>
<!-- Sidebar --> <!-- Sidebar -->
@@ -298,9 +840,102 @@ const platformLabel = computed<string>(() => {
cols="12" cols="12"
lg="4" lg="4"
> >
<!-- Service Overview Card -->
<VCard class="mb-6">
<VCardTitle>
<VIcon
icon="tabler-info-circle"
class="me-2"
/>
Service Overview
</VCardTitle>
<VCardText>
<VList density="compact">
<VListItem>
<template #prepend>
<VIcon
icon="tabler-hash"
size="20"
class="me-2"
/>
</template>
<VListItemTitle class="text-body-2 text-medium-emphasis">
Service ID
</VListItemTitle>
<VListItemSubtitle class="text-body-1">
#{{ service.id }}
</VListItemSubtitle>
</VListItem>
<VListItem v-if="service.platform_service_id">
<template #prepend>
<VIcon
icon="tabler-link"
size="20"
class="me-2"
/>
</template>
<VListItemTitle class="text-body-2 text-medium-emphasis">
External ID
</VListItemTitle>
<VListItemSubtitle class="text-body-1">
{{ service.platform_service_id }}
</VListItemSubtitle>
</VListItem>
<VListItem>
<template #prepend>
<VIcon
icon="tabler-circle-dot"
size="20"
class="me-2"
/>
</template>
<VListItemTitle class="text-body-2 text-medium-emphasis">
Status
</VListItemTitle>
<VListItemSubtitle>
<VChip
:color="resolveServiceStatusColor(service.status)"
size="small"
class="text-capitalize mt-1"
>
{{ service.status }}
</VChip>
</VListItemSubtitle>
</VListItem>
<VListItem>
<template #prepend>
<VIcon
icon="tabler-category"
size="20"
class="me-2"
/>
</template>
<VListItemTitle class="text-body-2 text-medium-emphasis">
Type
</VListItemTitle>
<VListItemSubtitle>
<VChip
:color="resolveServiceTypeColor(service.service_type)"
size="small"
class="mt-1"
>
{{ serviceTypeLabel }}
</VChip>
</VListItemSubtitle>
</VListItem>
</VList>
</VCardText>
</VCard>
<!-- Important Dates --> <!-- Important Dates -->
<VCard class="mb-6"> <VCard class="mb-6">
<VCardTitle>Important Dates</VCardTitle> <VCardTitle>
<VIcon
icon="tabler-calendar"
class="me-2"
/>
Important Dates
</VCardTitle>
<VCardText> <VCardText>
<div class="d-flex flex-column ga-4"> <div class="d-flex flex-column ga-4">
<div> <div>
@@ -349,7 +984,13 @@ const platformLabel = computed<string>(() => {
<!-- Quick Actions --> <!-- Quick Actions -->
<VCard v-if="!isTerminated"> <VCard v-if="!isTerminated">
<VCardTitle>Quick Actions</VCardTitle> <VCardTitle>
<VIcon
icon="tabler-bolt"
class="me-2"
/>
Quick Actions
</VCardTitle>
<VCardText> <VCardText>
<div class="d-flex flex-column ga-3"> <div class="d-flex flex-column ga-3">
<Link <Link
@@ -382,7 +1023,7 @@ const platformLabel = computed<string>(() => {
icon="tabler-external-link" icon="tabler-external-link"
start start
/> />
Open Control Panel Open {{ platformLabel }} Panel
</VBtn> </VBtn>
<Link <Link
@@ -402,6 +1043,22 @@ const platformLabel = computed<string>(() => {
</VBtn> </VBtn>
</Link> </Link>
<Link
href="/tickets/create"
class="text-decoration-none"
>
<VBtn
block
variant="tonal"
>
<VIcon
icon="tabler-headset"
start
/>
Get Support
</VBtn>
</Link>
<Link <Link
href="/billing" href="/billing"
class="text-decoration-none" class="text-decoration-none"

View File

@@ -126,6 +126,7 @@ export interface Service {
hostname: string | null hostname: string | null
domain: string | null domain: string | null
auto_renew: boolean auto_renew: boolean
provisioning_info: Record<string, unknown> | null
provisioned_at: string | null provisioned_at: string | null
suspended_at: string | null suspended_at: string | null
terminated_at: string | null terminated_at: string | null