From bdd168d26d5a36383dd1c43ec4b28c6809d00b29 Mon Sep 17 00:00:00 2001 From: "Joseph.Rawlings" Date: Thu, 18 Dec 2025 20:38:12 -0500 Subject: [PATCH] feat: implement server tags management with Proxmox-style color generation and syncing --- .../Http/Controllers/ServerTagController.php | 155 ++++++++++++++ extensions/Servers/Proxmox/Models/Server.php | 6 + ...000000_add_tags_to_ext_proxmox_servers.php | 30 +++ .../Proxmox/views/components/layout.blade.php | 199 ++++++++++++++++++ .../Servers/Proxmox/views/server.blade.php | 14 +- routes/web.php | 4 + 6 files changed, 407 insertions(+), 1 deletion(-) create mode 100644 extensions/Servers/Proxmox/Http/Controllers/ServerTagController.php create mode 100644 extensions/Servers/Proxmox/database/migrations/2025_12_18_000000_add_tags_to_ext_proxmox_servers.php diff --git a/extensions/Servers/Proxmox/Http/Controllers/ServerTagController.php b/extensions/Servers/Proxmox/Http/Controllers/ServerTagController.php new file mode 100644 index 000000000..a983dacb6 --- /dev/null +++ b/extensions/Servers/Proxmox/Http/Controllers/ServerTagController.php @@ -0,0 +1,155 @@ +where('user_id', Auth::id()) + ->firstOrFail(); + + return response()->json([ + 'success' => true, + 'tags' => $server->tags ?? [], + ]); + } + + /** + * Update tags for a server + */ + public function update(Request $request, $serverId) + { + $request->validate([ + 'tags' => 'nullable|array|max:3', + 'tags.*.text' => 'required|string|max:32|regex:/^[a-zA-Z0-9_-]+$/', + 'tags.*.color' => 'nullable|string|max:16', + ]); + + $server = Server::where('id', $serverId) + ->where('user_id', Auth::id()) + ->firstOrFail(); + + $tags = $request->input('tags', []); + + // Limit to 3 tags + $tags = array_slice($tags, 0, 3); + + // Process tags and auto-generate colors if not provided + $processedTags = []; + foreach ($tags as $tag) { + $text = trim($tag['text']); + if (empty($text)) continue; + + // Convert to lowercase and sanitize (Proxmox requires this) + $text = strtolower(preg_replace('/[^a-zA-Z0-9_-]/', '', $text)); + if (empty($text)) continue; + + $processedTags[] = [ + 'text' => $text, + 'color' => $tag['color'] ?? self::generateTagColor($text), + ]; + } + + // Update local database + $server->tags = $processedTags; + $server->save(); + + // Sync to Proxmox + $proxmoxSuccess = $this->syncTagsToProxmox($server, $processedTags); + + return response()->json([ + 'success' => true, + 'tags' => $processedTags, + 'proxmox_synced' => $proxmoxSuccess, + ]); + } + + /** + * Sync tags to Proxmox VM + */ + private function syncTagsToProxmox(Server $server, array $tags): bool + { + try { + $proxmox = new Proxmox; + + // Convert tags array to Proxmox format (semicolon-separated) + // Proxmox stores tags as a simple semicolon-delimited string + $tagString = implode(';', array_column($tags, 'text')); + + // Update VM config with tags + $proxmox->request('/nodes/' . $server->node->name . '/qemu/' . $server->vm_id . '/config', 'put', [ + 'tags' => $tagString, + ]); + + return true; + } catch (\Exception $e) { + // Log the error but don't fail the request + \Log::error('Failed to sync tags to Proxmox for server ' . $server->id, [ + 'error' => $e->getMessage(), + 'server_id' => $server->id, + ]); + return false; + } + } + + /** + * Get available tag colors + */ + public function colors() + { + return response()->json([ + 'success' => true, + 'colors' => self::$proxmoxTagColors, + ]); + } +} diff --git a/extensions/Servers/Proxmox/Models/Server.php b/extensions/Servers/Proxmox/Models/Server.php index 508534ae6..2b812b889 100644 --- a/extensions/Servers/Proxmox/Models/Server.php +++ b/extensions/Servers/Proxmox/Models/Server.php @@ -22,6 +22,12 @@ class Server extends Model 'password', 'user_set_icon', 'user_set_icon_color', + 'user_set_icon_prefix', + 'tags', + ]; + + protected $casts = [ + 'tags' => 'array', ]; public function node() diff --git a/extensions/Servers/Proxmox/database/migrations/2025_12_18_000000_add_tags_to_ext_proxmox_servers.php b/extensions/Servers/Proxmox/database/migrations/2025_12_18_000000_add_tags_to_ext_proxmox_servers.php new file mode 100644 index 000000000..7dacf895c --- /dev/null +++ b/extensions/Servers/Proxmox/database/migrations/2025_12_18_000000_add_tags_to_ext_proxmox_servers.php @@ -0,0 +1,30 @@ +json('tags')->nullable()->after('user_set_icon_prefix'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('ext_proxmox_servers', function (Blueprint $table) { + $table->dropColumn('tags'); + }); + } +}; diff --git a/extensions/Servers/Proxmox/views/components/layout.blade.php b/extensions/Servers/Proxmox/views/components/layout.blade.php index c60d685ae..80d44098e 100644 --- a/extensions/Servers/Proxmox/views/components/layout.blade.php +++ b/extensions/Servers/Proxmox/views/components/layout.blade.php @@ -1224,9 +1224,208 @@ } } } + + // Server Tags Manager - Proxmox-style tags with color generation + function serverTagsManager({serverId, initialTags}) { + return { + show: false, + saving: false, + tags: initialTags || [], + editTags: [], + colorPickerIndex: null, + message: '', + messageType: 'success', + // Proxmox-style tag colors + tagColors: [ + '#e74c3c', '#e67e22', '#f1c40f', '#2ecc71', '#1abc9c', '#3498db', + '#9b59b6', '#e91e63', '#00bcd4', '#8bc34a', '#ff5722', '#607d8b', + '#795548', '#9e9e9e', '#673ab7', '#4caf50' + ], + openModal() { + this.show = true; + this.message = ''; + this.colorPickerIndex = null; + // Clone tags for editing + this.editTags = JSON.parse(JSON.stringify(this.tags)); + }, + closeModal() { + this.show = false; + this.colorPickerIndex = null; + }, + addTag() { + if (this.editTags.length < 3) { + this.editTags.push({ + text: '', + color: this.generateTagColor('') + }); + } + }, + removeTag(index) { + this.editTags.splice(index, 1); + if (this.colorPickerIndex === index) { + this.colorPickerIndex = null; + } else if (this.colorPickerIndex > index) { + this.colorPickerIndex--; + } + }, + selectColorFor(index) { + this.colorPickerIndex = this.colorPickerIndex === index ? null : index; + }, + setTagColor(index, color) { + if (this.editTags[index]) { + 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() { + this.saving = true; + this.message = ''; + + // Filter out empty tags + const validTags = this.editTags.filter(tag => tag.text && tag.text.trim()); + + fetch(`/proxmox/server/${serverId}/tags`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content + }, + body: JSON.stringify({ tags: validTags }) + }) + .then(r => r.json()) + .then(data => { + this.saving = false; + if (data.success) { + this.tags = data.tags; + this.message = data.proxmox_synced + ? 'Tags saved and synced to Proxmox!' + : 'Tags saved (Proxmox sync failed)'; + this.messageType = 'success'; + setTimeout(() => { this.closeModal(); location.reload(); }, 1000); + } else { + this.message = 'Failed to save tags'; + this.messageType = 'error'; + } + }) + .catch(err => { + this.saving = false; + this.message = 'Error saving tags'; + this.messageType = 'error'; + }); + } + } + } + +
+ +
+ +
+ + + + + +
+
+ +

Manage Server Tags

+

Add up to 3 tags to organize and identify your server. Tags are synced to Proxmox.

+ + +
+ +
+ + + + + + + +
+ + +
+ + +
+
+
+
- Bandwidth + Bandwidth (95% threshold: {{ $this->bandwidthThreshold95 }} GiB)
{{ $this->monthlyTraffic }} GiB / {{ $this->bandwidthThreshold95 }} GiB diff --git a/routes/web.php b/routes/web.php index 6fc19a96c..e80709e56 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ group(function () { Route::get('/proxmox/server/{server}/icon', [ServerIconController::class, 'show']); Route::post('/proxmox/server/{server}/icon', [ServerIconController::class, 'update']); + Route::get('/proxmox/server/{server}/tags', [ServerTagController::class, 'show']); + Route::post('/proxmox/server/{server}/tags', [ServerTagController::class, 'update']); + Route::get('/proxmox/tags/colors', [ServerTagController::class, 'colors']); }); Route::get('/', Home::class)->name('home');