feat: implement server tag management with individual fields and update routes

This commit is contained in:
2025-12-18 21:09:45 -05:00
parent db04b8a6c3
commit b0f7c7f20d
6 changed files with 167 additions and 217 deletions

View File

@@ -8,58 +8,6 @@ use Paymenter\Extensions\Servers\Proxmox\Models\Server;
class ServerTagController class ServerTagController
{ {
/**
* Proxmox-style tag colors
*/
private static $proxmoxTagColors = [
'#e74c3c', // Red
'#e67e22', // Orange
'#f1c40f', // Yellow
'#2ecc71', // Green
'#1abc9c', // Teal
'#3498db', // Blue
'#9b59b6', // Purple
'#e91e63', // Pink
'#00bcd4', // Cyan
'#8bc34a', // Light Green
'#ff5722', // Deep Orange
'#607d8b', // Blue Grey
'#795548', // Brown
'#9e9e9e', // Grey
'#673ab7', // Deep Purple
'#4caf50', // Green Material
];
/**
* Generate a consistent color for a tag based on its text
*/
public static function generateTagColor(string $tagText): string
{
$hash = 0;
for ($i = 0; $i < strlen($tagText); $i++) {
$hash = ord($tagText[$i]) + (($hash << 5) - $hash);
$hash = $hash & $hash;
}
$index = abs($hash) % count(self::$proxmoxTagColors);
return self::$proxmoxTagColors[$index];
}
/**
* Get tags for a server
*/
public function show($serverId)
{
$server = Server::where('id', $serverId)
->where('user_id', Auth::id())
->firstOrFail();
return response()->json([
'success' => true,
'tags' => $server->tags ?? [],
]);
}
/** /**
* Update tags for a server * Update tags for a server
*/ */
@@ -70,44 +18,15 @@ class ServerTagController
->where('user_id', Auth::id()) ->where('user_id', Auth::id())
->firstOrFail(); ->firstOrFail();
$tags = $request->input('tags', []); $server->tag1 = $request->input('tag1');
$server->tag1color = $request->input('tag1color');
if (!is_array($tags)) { $server->tag2 = $request->input('tag2');
$tags = []; $server->tag2color = $request->input('tag2color');
} $server->tag3 = $request->input('tag3');
$server->tag3color = $request->input('tag3color');
$tags = array_slice($tags, 0, 3);
$processedTags = [];
foreach ($tags as $tag) {
if (!is_array($tag) || !isset($tag['text'])) continue;
$text = trim($tag['text'] ?? '');
if (empty($text)) continue;
$text = strtolower(preg_replace('/[^a-zA-Z0-9_-]/', '', $text));
if (empty($text)) continue;
$text = substr($text, 0, 32);
$processedTags[] = [
'text' => $text,
'color' => $tag['color'] ?? self::generateTagColor($text),
];
}
$server->tag1 = $processedTags[0]['text'] ?? null;
$server->tag1color = $processedTags[0]['color'] ?? null;
$server->tag2 = $processedTags[1]['text'] ?? null;
$server->tag2color = $processedTags[1]['color'] ?? null;
$server->tag3 = $processedTags[2]['text'] ?? null;
$server->tag3color = $processedTags[2]['color'] ?? null;
$server->save(); $server->save();
return response()->json([ return response()->json(['success' => true]);
'success' => true,
'tags' => $processedTags,
]);
} catch (\Exception $e) { } catch (\Exception $e) {
return response()->json([ return response()->json([
'success' => false, 'success' => false,
@@ -115,15 +34,4 @@ class ServerTagController
], 500); ], 500);
} }
} }
/**
* Get available tag colors
*/
public function colors()
{
return response()->json([
'success' => true,
'colors' => self::$proxmoxTagColors,
]);
}
} }

View File

@@ -31,26 +31,6 @@ class Server extends Model
'tag3color', 'tag3color',
]; ];
/**
* Get tags as an array of objects
*/
public function getTagsAttribute(): array
{
$tags = [];
if (!empty($this->tag1)) {
$tags[] = ['text' => $this->tag1, 'color' => $this->tag1color ?? '#3498db'];
}
if (!empty($this->tag2)) {
$tags[] = ['text' => $this->tag2, 'color' => $this->tag2color ?? '#3498db'];
}
if (!empty($this->tag3)) {
$tags[] = ['text' => $this->tag3, 'color' => $this->tag3color ?? '#3498db'];
}
return $tags;
}
public function node() public function node()
{ {
return $this->belongsTo(Node::class); return $this->belongsTo(Node::class);

View File

@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('ext_proxmox_servers', function (Blueprint $table) {
if (!Schema::hasColumn('ext_proxmox_servers', 'tag1')) {
$table->longText('tag1')->nullable();
}
if (!Schema::hasColumn('ext_proxmox_servers', 'tag1color')) {
$table->longText('tag1color')->nullable();
}
if (!Schema::hasColumn('ext_proxmox_servers', 'tag2')) {
$table->longText('tag2')->nullable();
}
if (!Schema::hasColumn('ext_proxmox_servers', 'tag2color')) {
$table->longText('tag2color')->nullable();
}
if (!Schema::hasColumn('ext_proxmox_servers', 'tag3')) {
$table->longText('tag3')->nullable();
}
if (!Schema::hasColumn('ext_proxmox_servers', 'tag3color')) {
$table->longText('tag3color')->nullable();
}
});
}
public function down(): void
{
Schema::table('ext_proxmox_servers', function (Blueprint $table) {
$table->dropColumn(['tag1', 'tag1color', 'tag2', 'tag2color', 'tag3', 'tag3color']);
});
}
};

View File

@@ -42,13 +42,11 @@ Route::group([
Route::get('{server}/help', Help::class)->name('server.help'); Route::get('{server}/help', Help::class)->name('server.help');
}); });
// Server Tag Routes - separate prefix to match frontend URL // Server Tag Routes
Route::group([ Route::group([
'prefix' => 'proxmox/server', 'prefix' => 'proxmox/server',
'as' => 'proxmox.server.', 'as' => 'proxmox.server.',
'middleware' => ['web', 'auth'], 'middleware' => ['web', 'auth'],
], function () { ], function () {
Route::get('{server}/tags', [ServerTagController::class, 'show'])->name('tags.show');
Route::post('{server}/tags', [ServerTagController::class, 'update'])->name('tags.update'); Route::post('{server}/tags', [ServerTagController::class, 'update'])->name('tags.update');
Route::get('tags/colors', [ServerTagController::class, 'colors'])->name('tags.colors');
}); });

View File

@@ -1225,18 +1225,21 @@
} }
} }
// Server Tags Manager - Proxmox-style tags with color generation // Server Tags Manager - Simple tags stored in database
function serverTagsManager({serverId, initialTags}) { function serverTagsManager({serverId, tag1, tag1color, tag2, tag2color, tag3, tag3color}) {
return { return {
serverId: serverId, serverId: serverId,
show: false, show: false,
saving: false, saving: false,
tags: initialTags || [], tag1: tag1 || '',
editTags: [], tag1color: tag1color || '#3498db',
colorPickerIndex: null, tag2: tag2 || '',
tag2color: tag2color || '#2ecc71',
tag3: tag3 || '',
tag3color: tag3color || '#e74c3c',
colorPickerFor: null,
message: '', message: '',
messageType: 'success', messageType: 'success',
// Proxmox-style tag colors
tagColors: [ tagColors: [
'#e74c3c', '#e67e22', '#f1c40f', '#2ecc71', '#1abc9c', '#3498db', '#e74c3c', '#e67e22', '#f1c40f', '#2ecc71', '#1abc9c', '#3498db',
'#9b59b6', '#e91e63', '#00bcd4', '#8bc34a', '#ff5722', '#607d8b', '#9b59b6', '#e91e63', '#00bcd4', '#8bc34a', '#ff5722', '#607d8b',
@@ -1245,74 +1248,55 @@
openModal() { openModal() {
this.show = true; this.show = true;
this.message = ''; this.message = '';
this.colorPickerIndex = null; this.colorPickerFor = null;
// Clone tags for editing
this.editTags = JSON.parse(JSON.stringify(this.tags));
}, },
closeModal() { closeModal() {
this.show = false; this.show = false;
this.colorPickerIndex = null; this.colorPickerFor = null;
}, },
addTag() { selectColorFor(tagNum) {
if (this.editTags.length < 3) { this.colorPickerFor = this.colorPickerFor === tagNum ? null : tagNum;
this.editTags.push({
text: '',
color: this.generateTagColor('')
});
}
}, },
removeTag(index) { setColor(tagNum, color) {
this.editTags.splice(index, 1); if (tagNum === 1) this.tag1color = color;
if (this.colorPickerIndex === index) { else if (tagNum === 2) this.tag2color = color;
this.colorPickerIndex = null; else if (tagNum === 3) this.tag3color = color;
} else if (this.colorPickerIndex > index) { this.colorPickerFor = null;
this.colorPickerIndex--;
}
}, },
selectColorFor(index) { getColor(tagNum) {
this.colorPickerIndex = this.colorPickerIndex === index ? null : index; if (tagNum === 1) return this.tag1color;
}, if (tagNum === 2) return this.tag2color;
setTagColor(index, color) { if (tagNum === 3) return this.tag3color;
if (this.editTags[index]) { return '#3498db';
this.editTags[index].color = color;
}
this.colorPickerIndex = null;
},
generateTagColor(text) {
// Same algorithm as backend for consistency
let hash = 0;
for (let i = 0; i < text.length; i++) {
hash = text.charCodeAt(i) + ((hash << 5) - hash);
hash = hash & hash;
}
const index = Math.abs(hash) % this.tagColors.length;
return this.tagColors[index];
}, },
saveTags() { saveTags() {
this.saving = true; this.saving = true;
this.message = ''; this.message = '';
// Filter out empty tags
const validTags = this.editTags.filter(tag => tag.text && tag.text.trim());
fetch(`/proxmox/server/${this.serverId}/tags`, { fetch(`/proxmox/server/${this.serverId}/tags`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content 'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content
}, },
body: JSON.stringify({ tags: validTags }) body: JSON.stringify({
tag1: this.tag1.trim(),
tag1color: this.tag1color,
tag2: this.tag2.trim(),
tag2color: this.tag2color,
tag3: this.tag3.trim(),
tag3color: this.tag3color
})
}) })
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
this.saving = false; this.saving = false;
if (data.success) { if (data.success) {
this.tags = data.tags;
this.message = 'Tags saved!'; this.message = 'Tags saved!';
this.messageType = 'success'; this.messageType = 'success';
setTimeout(() => { this.closeModal(); location.reload(); }, 1000); setTimeout(() => { this.closeModal(); location.reload(); }, 1000);
} else { } else {
this.message = 'Failed to save tags'; this.message = data.error || 'Failed to save tags';
this.messageType = 'error'; this.messageType = 'error';
} }
}) })
@@ -1330,15 +1314,34 @@
<!-- Server Tags Component --> <!-- Server Tags Component -->
<div x-data="serverTagsManager({ <div x-data="serverTagsManager({
serverId: '{{ $server->id }}', serverId: '{{ $server->id }}',
initialTags: {{ json_encode($server->tags ?? []) }} tag1: '{{ $server->tag1 ?? '' }}',
tag1color: '{{ $server->tag1color ?? '#3498db' }}',
tag2: '{{ $server->tag2 ?? '' }}',
tag2color: '{{ $server->tag2color ?? '#2ecc71' }}',
tag3: '{{ $server->tag3 ?? '' }}',
tag3color: '{{ $server->tag3color ?? '#e74c3c' }}'
})" class="flex items-center gap-2"> })" class="flex items-center gap-2">
<!-- Display Tags --> <!-- Display Tags -->
<div class="flex items-center gap-1.5 flex-wrap"> <div class="flex items-center gap-1.5 flex-wrap">
<template x-for="(tag, index) in tags" :key="index"> <template x-if="tag1">
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium text-white cursor-pointer hover:opacity-80 transition-opacity" <span class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium text-white cursor-pointer hover:opacity-80 transition-opacity"
:style="'background-color: ' + tag.color" :style="'background-color: ' + tag1color"
@click="openModal()"> @click="openModal()">
<span x-text="tag.text"></span> <span x-text="tag1"></span>
</span>
</template>
<template x-if="tag2">
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium text-white cursor-pointer hover:opacity-80 transition-opacity"
:style="'background-color: ' + tag2color"
@click="openModal()">
<span x-text="tag2"></span>
</span>
</template>
<template x-if="tag3">
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium text-white cursor-pointer hover:opacity-80 transition-opacity"
:style="'background-color: ' + tag3color"
@click="openModal()">
<span x-text="tag3"></span>
</span> </span>
</template> </template>
</div> </div>
@@ -1357,46 +1360,58 @@
<x-ri-close-line class="size-5" /> <x-ri-close-line class="size-5" />
</button> </button>
<h3 class="text-lg font-semibold mb-4">Manage Server Tags</h3> <h3 class="text-lg font-semibold mb-4">Manage Server Tags</h3>
<p class="text-sm text-base/60 mb-4">Add up to 3 tags to organize and identify your server. Tags are synced to Proxmox.</p> <p class="text-sm text-base/60 mb-4">Add up to 3 tags to organize and identify your server.</p>
<!-- Current Tags --> <!-- Tag 1 -->
<div class="space-y-3 mb-4"> <div class="space-y-3 mb-4">
<template x-for="(tag, index) in editTags" :key="index"> <div class="flex items-center gap-2">
<div class="flex items-center gap-2"> <span class="text-xs text-base/50 w-12">Tag 1:</span>
<input type="text" <input type="text" x-model="tag1" placeholder="Tag 1"
x-model="editTags[index].text" class="flex-1 px-3 py-2 text-sm border border-neutral rounded-lg dark:bg-background dark:border-base/20 focus:ring-2 focus:ring-primary/50 focus:border-primary"
placeholder="Tag name (a-z, 0-9, -, _)" maxlength="32" />
class="flex-1 px-3 py-2 text-sm border border-neutral rounded-lg dark:bg-background dark:border-base/20 focus:ring-2 focus:ring-primary/50 focus:border-primary" <button @click="selectColorFor(1)"
maxlength="32" class="size-8 rounded-full border-2 border-white shadow-md transition-transform hover:scale-110"
@input="editTags[index].text = editTags[index].text.toLowerCase().replace(/[^a-z0-9_-]/g, '')" /> :style="'background-color: ' + tag1color"
<button @click="selectColorFor(index)" title="Change color">
class="size-8 rounded-full border-2 border-white shadow-md transition-transform hover:scale-110" </button>
:style="'background-color: ' + editTags[index].color" </div>
title="Change color">
</button> <!-- Tag 2 -->
<button @click="removeTag(index)" class="text-base/50 hover:text-danger transition-colors" title="Remove tag"> <div class="flex items-center gap-2">
<x-ri-delete-bin-line class="size-5" /> <span class="text-xs text-base/50 w-12">Tag 2:</span>
</button> <input type="text" x-model="tag2" placeholder="Tag 2"
</div> class="flex-1 px-3 py-2 text-sm border border-neutral rounded-lg dark:bg-background dark:border-base/20 focus:ring-2 focus:ring-primary/50 focus:border-primary"
</template> maxlength="32" />
<button @click="selectColorFor(2)"
class="size-8 rounded-full border-2 border-white shadow-md transition-transform hover:scale-110"
:style="'background-color: ' + tag2color"
title="Change color">
</button>
</div>
<!-- Tag 3 -->
<div class="flex items-center gap-2">
<span class="text-xs text-base/50 w-12">Tag 3:</span>
<input type="text" x-model="tag3" placeholder="Tag 3"
class="flex-1 px-3 py-2 text-sm border border-neutral rounded-lg dark:bg-background dark:border-base/20 focus:ring-2 focus:ring-primary/50 focus:border-primary"
maxlength="32" />
<button @click="selectColorFor(3)"
class="size-8 rounded-full border-2 border-white shadow-md transition-transform hover:scale-110"
:style="'background-color: ' + tag3color"
title="Change color">
</button>
</div>
</div> </div>
<!-- Add Tag Button --> <!-- Color Picker -->
<template x-if="editTags.length < 3"> <template x-if="colorPickerFor !== null">
<button @click="addTag()" class="w-full py-2 border border-dashed border-base/30 rounded-lg text-sm text-base/60 hover:border-primary hover:text-primary transition-colors mb-4">
<x-ri-add-line class="size-4 inline" /> Add Tag
</button>
</template>
<!-- Color Picker (shown when selecting color) -->
<template x-if="colorPickerIndex !== null">
<div class="mb-4 p-3 bg-background rounded-lg border border-neutral"> <div class="mb-4 p-3 bg-background rounded-lg border border-neutral">
<p class="text-xs text-base/60 mb-2">Select color for tag:</p> <p class="text-xs text-base/60 mb-2">Select color:</p>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<template x-for="c in tagColors" :key="c"> <template x-for="c in tagColors" :key="c">
<button @click="setTagColor(colorPickerIndex, c)" <button @click="setColor(colorPickerFor, c)"
:style="'background-color: ' + c" :style="'background-color: ' + c"
:class="editTags[colorPickerIndex]?.color === c ? 'ring-2 ring-offset-2 ring-primary' : ''" :class="getColor(colorPickerFor) === c ? 'ring-2 ring-offset-2 ring-primary' : ''"
class="size-7 rounded-full border border-white shadow-sm hover:scale-110 transition-transform"> class="size-7 rounded-full border border-white shadow-sm hover:scale-110 transition-transform">
</button> </button>
</template> </template>

View File

@@ -24,17 +24,27 @@
@endif @endif
<span class="font-medium">{{ $status->name }}</span> <span class="font-medium">{{ $status->name }}</span>
{{-- Server Tags (Proxmox-style) --}} {{-- Server Tags --}}
@if($server->tags && count($server->tags) > 0) <div class="flex items-center gap-1.5 ml-2">
<div class="flex items-center gap-1.5 ml-2"> @if($server->tag1)
@foreach($server->tags as $tag) <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium text-white"
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium text-white" style="background-color: {{ $server->tag1color ?? '#3498db' }};">
style="background-color: {{ $tag['color'] ?? '#6366f1' }};"> {{ $server->tag1 }}
{{ $tag['text'] }} </span>
</span> @endif
@endforeach @if($server->tag2)
</div> <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium text-white"
@endif style="background-color: {{ $server->tag2color ?? '#2ecc71' }};">
{{ $server->tag2 }}
</span>
@endif
@if($server->tag3)
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium text-white"
style="background-color: {{ $server->tag3color ?? '#e74c3c' }};">
{{ $server->tag3 }}
</span>
@endif
</div>
</div> </div>
<div class="flex items-center gap-2 px-3 py-1.5 rounded-lg <div class="flex items-center gap-2 px-3 py-1.5 rounded-lg
@if($status->status == 'running') text-success bg-success/20 @if($status->status == 'running') text-success bg-success/20