From 0946b9a16f2eaf7d4b90ef2a3efa3a7e129e987a Mon Sep 17 00:00:00 2001 From: Irfan Hardiyanto <52022757+devhardiyanto@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:42:39 +0700 Subject: [PATCH 1/6] fix(tauri): koreksi deteksi versi aktif di submenu tray (sumber otoritatif) Tray tak lagi mengandalkan flag active hasil parse listing (fnm list menandai * di setiap baris terpasang -> semua tampil aktif). Penanda ikut php_get_active/node_get_active, konsisten dgn frontend. devhardiyanto --- src-tauri/src/tray.rs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index 91a94cd..d0dba37 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -82,18 +82,32 @@ fn build_version_submenu( /// Ambil daftar versi PHP & Node lalu rebuild menu tray (set_menu). Dipanggil /// async setelah init karena listing versi butuh spawn subprocess (phpvm/fnm). -async fn refresh_version_submenus(app: &AppHandle) { +/// +/// Penanda aktif (●) diambil dari sumber otoritatif `php_get_active()` / +/// `node_get_active()` (bukan flag `active` hasil parse listing) — konsisten +/// dengan frontend, dan tahan terhadap output listing yang menandai `*` di +/// setiap baris (mis. `fnm list`). +pub(crate) async fn refresh_version_submenus(app: &AppHandle) { + let php_active = crate::commands::php::php_get_active().await.ok().flatten(); let php: Vec<(String, bool)> = crate::commands::php::php_list_installed() .await .unwrap_or_default() .into_iter() - .map(|v| (v.version, v.active)) + .map(|v| { + let active = php_active.as_deref() == Some(v.version.as_str()); + (v.version, active) + }) .collect(); + + let node_active = crate::commands::node::node_get_active().await.ok().flatten(); let node: Vec<(String, bool)> = crate::commands::node::node_list_installed() .await .unwrap_or_default() .into_iter() - .map(|v| (v.version, v.active)) + .map(|v| { + let active = node_active.as_deref() == Some(v.version.as_str()); + (v.version, active) + }) .collect(); match build_menu(app, &php, &node) { From 43191ae91dd5ec53c703b7d87fa670d139ccfcc1 Mon Sep 17 00:00:00 2001 From: Irfan Hardiyanto <52022757+devhardiyanto@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:42:39 +0700 Subject: [PATCH 2/6] fix(tauri): rebuild submenu tray setelah switch versi (bullet ikut aktif) Panggil refresh_version_submenus pasca php_switch/node_switch sukses, lewat window.app_handle(). Bullet aktif selalu sinkron apa pun pemicunya (tray atau UI), tak perlu restart app. devhardiyanto --- src-tauri/src/commands/node.rs | 9 ++++++++- src-tauri/src/commands/php.rs | 9 ++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/commands/node.rs b/src-tauri/src/commands/node.rs index 7d0ad91..da81fa4 100644 --- a/src-tauri/src/commands/node.rs +++ b/src-tauri/src/commands/node.rs @@ -1,7 +1,7 @@ use std::process::Stdio; use tokio::io::{AsyncBufReadExt, BufReader}; use serde::{Deserialize, Serialize}; -use tauri::{Emitter, Window}; +use tauri::{Emitter, Manager, Window}; use super::util::{emit_env_line, extract_semver, silent_command}; @@ -112,6 +112,13 @@ pub async fn node_switch(window: Window, version: String) -> Result<(), String> } emit_env_line(&window, &format!("switched node → {} (via fnm)", version)); + + // Rebuild submenu tray agar ● ikut versi aktif terbaru (apa pun pemicunya). + let app = window.app_handle().clone(); + tauri::async_runtime::spawn(async move { + crate::tray::refresh_version_submenus(&app).await; + }); + Ok(()) } diff --git a/src-tauri/src/commands/php.rs b/src-tauri/src/commands/php.rs index 68bceef..fcd97ad 100644 --- a/src-tauri/src/commands/php.rs +++ b/src-tauri/src/commands/php.rs @@ -1,7 +1,7 @@ use std::process::Stdio; use tokio::io::{AsyncBufReadExt, BufReader}; use serde::{Deserialize, Serialize}; -use tauri::{Emitter, Window}; +use tauri::{Emitter, Manager, Window}; use super::util::{emit_env_line, extract_semver}; @@ -130,6 +130,13 @@ pub async fn php_switch(window: Window, version: String) -> Result<(), String> { } emit_env_line(&window, &format!("switched php → {}", version)); + + // Rebuild submenu tray agar ● ikut versi aktif terbaru (apa pun pemicunya). + let app = window.app_handle().clone(); + tauri::async_runtime::spawn(async move { + crate::tray::refresh_version_submenus(&app).await; + }); + Ok(()) } From 2c1f4798b5de3970ae5bccbb0a01d6bd395c7df0 Mon Sep 17 00:00:00 2001 From: Irfan Hardiyanto <52022757+devhardiyanto@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:56:42 +0700 Subject: [PATCH 3/6] feat(tauri): skema config profiles + migrasi v1->v2 Tambah Profile{id,name,serviceIds} + profiles/activeProfileId ke ConfigState. selected_service_ids jadi proyeksi profil aktif (di-sinkron saat write) agar tray & compose tak berubah. Migrasi backward-compat: config lama -> profil Default berisi selection existing, version bump ke 2. devhardiyanto --- src-tauri/src/commands/config.rs | 216 ++++++++++++++++++++++++++++--- 1 file changed, 195 insertions(+), 21 deletions(-) diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 3729d50..f1b0f57 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -4,14 +4,32 @@ use std::path::{Path, PathBuf}; use std::sync::Mutex; use tauri::{AppHandle, Manager}; +/// Preset pilihan service ber-nama. User bisa punya beberapa profil (mis. per +/// proyek) dan menukar set service aktif dengan meng-`apply` salah satunya. +/// `id` di-generate frontend (`crypto.randomUUID`); migrasi memakai id "default". +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Profile { + pub id: String, + pub name: String, + #[serde(default)] + pub service_ids: Vec, +} + #[derive(Serialize, Deserialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct ConfigState { #[serde(default)] pub version: u32, + /// Proyeksi `service_ids` profil aktif. Tray & compose membaca field ini — + /// selalu di-sinkronkan dari profil aktif saat write (lihat `config_write`). #[serde(default)] pub selected_service_ids: Vec, #[serde(default)] + pub profiles: Vec, + #[serde(default)] + pub active_profile_id: Option, + #[serde(default)] pub last_php_version: Option, #[serde(default)] pub last_node_version: Option, @@ -29,11 +47,69 @@ fn default_true() -> bool { true } +/// Migrasikan config lama ke skema terkini (`CURRENT_VERSION`). +/// +/// - v0 → v1: `version` absent (serde default 0) → bump ke 1. +/// - v1 → v2: config flat (`selected_service_ids`, tanpa `profiles`) → buat +/// profil "Default" berisi selection existing, set `active_profile_id`. +/// +/// Idempotent: config yang sudah v2 & punya profil valid dibiarkan apa adanya. +fn migrate(mut cfg: ConfigState) -> ConfigState { + if cfg.version == 0 { + cfg.version = 1; + } + + // v1 → v2: perkenalkan profiles. Config lama tak punya profil sama sekali. + if cfg.profiles.is_empty() { + cfg.profiles = vec![Profile { + id: DEFAULT_PROFILE_ID.to_string(), + name: "Default".to_string(), + service_ids: cfg.selected_service_ids.clone(), + }]; + cfg.active_profile_id = Some(DEFAULT_PROFILE_ID.to_string()); + } + + // Jaga invariant: active_profile_id harus menunjuk profil yang ada. + let active_valid = cfg + .active_profile_id + .as_ref() + .is_some_and(|id| cfg.profiles.iter().any(|p| &p.id == id)); + if !active_valid { + cfg.active_profile_id = cfg.profiles.first().map(|p| p.id.clone()); + } + + cfg.version = CURRENT_VERSION; + project_active_selection(&mut cfg); + cfg +} + +/// Sinkronkan `selected_service_ids` = `service_ids` profil aktif. Menjamin tray +/// & compose (yang membaca `selected_service_ids`) selalu match profil aktif. +fn project_active_selection(cfg: &mut ConfigState) { + if let Some(active_id) = cfg.active_profile_id.clone() { + if let Some(p) = cfg.profiles.iter().find(|p| p.id == active_id) { + cfg.selected_service_ids = p.service_ids.clone(); + } + } +} + +/// Id profil bawaan yang dibuat saat migrasi/instal baru. +const DEFAULT_PROFILE_ID: &str = "default"; + +/// Skema config terkini. Naikkan saat ada perubahan struktur yang butuh migrasi. +const CURRENT_VERSION: u32 = 2; + impl Default for ConfigState { fn default() -> Self { Self { - version: 1, + version: CURRENT_VERSION, selected_service_ids: Vec::new(), + profiles: vec![Profile { + id: DEFAULT_PROFILE_ID.to_string(), + name: "Default".to_string(), + service_ids: Vec::new(), + }], + active_profile_id: Some(DEFAULT_PROFILE_ID.to_string()), last_php_version: None, last_node_version: None, watched_path: None, @@ -108,7 +184,7 @@ pub async fn config_read(app: AppHandle) -> Result { } }; - let mut cfg: ConfigState = match serde_json::from_str(&raw) { + let cfg: ConfigState = match serde_json::from_str(&raw) { Ok(c) => c, Err(err) => { eprintln!( @@ -119,10 +195,9 @@ pub async fn config_read(app: AppHandle) -> Result { } }; - // Migrasi v0 → v1: kalau version absent di JSON, serde(default) akan set 0. - if cfg.version == 0 { - cfg.version = 1; - } + // Migrasi ke skema terkini (v0→v1→v2). Config lama tetap kebaca lewat serde + // default lalu dinaikkan; selection lama masuk ke profil "Default". + let cfg = migrate(cfg); // Sync ke in-memory Mutex agar tray (yang baca Mutex) lihat selection tersimpan // tanpa harus menunggu user toggle ulang setelah app boot. @@ -136,9 +211,13 @@ pub async fn config_read(app: AppHandle) -> Result { } #[tauri::command] -pub async fn config_write(app: AppHandle, config: ConfigState) -> Result<(), String> { +pub async fn config_write(app: AppHandle, mut config: ConfigState) -> Result<(), String> { let path = config_path(&app)?; + // Invariant: selected_service_ids selalu = profil aktif (proyeksi). Tray & + // compose membaca field ini; frontend cukup update profil, backend proyeksi. + project_active_selection(&mut config); + let json = serde_json::to_string_pretty(&config) .map_err(|e| format!("gagal serialize config: {}", e))?; @@ -163,7 +242,7 @@ mod tests { #[test] fn test_default_config_shape() { let cfg = ConfigState::default(); - assert_eq!(cfg.version, 1); + assert_eq!(cfg.version, 2); assert!(!cfg.auto_start); assert!(cfg.remember_session); assert!(cfg.minimize_to_tray); @@ -171,13 +250,24 @@ mod tests { assert!(cfg.last_php_version.is_none()); assert!(cfg.last_node_version.is_none()); assert!(cfg.watched_path.is_none()); + // Instal baru: tepat 1 profil "Default" yang aktif. + assert_eq!(cfg.profiles.len(), 1); + assert_eq!(cfg.profiles[0].id, "default"); + assert_eq!(cfg.profiles[0].name, "Default"); + assert_eq!(cfg.active_profile_id.as_deref(), Some("default")); } #[test] fn test_serialize_deserialize_roundtrip() { let original = ConfigState { - version: 1, + version: 2, selected_service_ids: vec!["mysql".to_string(), "redis".to_string()], + profiles: vec![Profile { + id: "default".to_string(), + name: "Default".to_string(), + service_ids: vec!["mysql".to_string(), "redis".to_string()], + }], + active_profile_id: Some("default".to_string()), last_php_version: Some("8.3".to_string()), last_node_version: Some("20.0.0".to_string()), watched_path: Some("/home/user/project".to_string()), @@ -196,10 +286,16 @@ mod tests { assert!(json.contains("autoStart")); assert!(json.contains("rememberSession")); assert!(json.contains("minimizeToTray")); + assert!(json.contains("profiles")); + assert!(json.contains("activeProfileId")); + assert!(json.contains("serviceIds")); let restored: ConfigState = serde_json::from_str(&json).expect("deserialize gagal"); assert_eq!(restored.version, original.version); assert_eq!(restored.selected_service_ids, original.selected_service_ids); + assert_eq!(restored.profiles.len(), 1); + assert_eq!(restored.profiles[0].service_ids, original.profiles[0].service_ids); + assert_eq!(restored.active_profile_id, original.active_profile_id); assert_eq!(restored.last_php_version, original.last_php_version); assert_eq!(restored.last_node_version, original.last_node_version); assert_eq!(restored.watched_path, original.watched_path); @@ -237,8 +333,14 @@ mod tests { let path = dir.join("config.json"); let cfg = ConfigState { - version: 1, + version: 2, selected_service_ids: vec!["mysql".to_string(), "minio".to_string()], + profiles: vec![Profile { + id: "default".to_string(), + name: "Default".to_string(), + service_ids: vec!["mysql".to_string(), "minio".to_string()], + }], + active_profile_id: Some("default".to_string()), last_php_version: Some("8.3".to_string()), last_node_version: None, watched_path: Some("/tmp/proj".to_string()), @@ -265,23 +367,95 @@ mod tests { } #[test] - fn test_migrate_v0_to_v1() { - // JSON tanpa field "version" → serde(default) set 0 → harus di-bump ke 1 + fn test_migrate_v1_to_v2_builds_default_profile() { + // Config lama (v1: selectedServiceIds, tanpa profiles) → migrasi buat + // profil "Default" berisi selection existing + set active_profile_id. let json = r#"{ - "selectedServiceIds": ["postgres"], + "version": 1, + "selectedServiceIds": ["postgres", "redis"], "autoStart": false, "rememberSession": true, "minimizeToTray": false }"#; - let mut cfg: ConfigState = serde_json::from_str(json).expect("parse gagal"); - // Simulasi logika migrasi yang ada di config_read - if cfg.version == 0 { - cfg.version = 1; - } - - assert_eq!(cfg.version, 1); - assert_eq!(cfg.selected_service_ids, vec!["postgres"]); + let cfg: ConfigState = serde_json::from_str(json).expect("parse gagal"); + let cfg = migrate(cfg); + + assert_eq!(cfg.version, 2); + assert_eq!(cfg.profiles.len(), 1); + assert_eq!(cfg.profiles[0].id, "default"); + assert_eq!(cfg.profiles[0].name, "Default"); + assert_eq!(cfg.profiles[0].service_ids, vec!["postgres", "redis"]); + assert_eq!(cfg.active_profile_id.as_deref(), Some("default")); + // selected_service_ids tetap = proyeksi profil aktif. + assert_eq!(cfg.selected_service_ids, vec!["postgres", "redis"]); assert!(!cfg.minimize_to_tray); } + + #[test] + fn test_migrate_v0_absent_version() { + // JSON tanpa "version" → serde default 0 → naik ke v2 + profil Default. + let json = r#"{ "selectedServiceIds": ["mysql"] }"#; + let cfg: ConfigState = serde_json::from_str(json).expect("parse gagal"); + let cfg = migrate(cfg); + assert_eq!(cfg.version, 2); + assert_eq!(cfg.active_profile_id.as_deref(), Some("default")); + assert_eq!(cfg.profiles[0].service_ids, vec!["mysql"]); + } + + #[test] + fn test_migrate_v2_idempotent() { + // Config v2 dgn profil custom aktif tidak boleh diubah/ditimpa. + let original = ConfigState { + version: 2, + selected_service_ids: vec!["mongodb".to_string()], + profiles: vec![ + Profile { + id: "p-abc".to_string(), + name: "Kerja".to_string(), + service_ids: vec!["mongodb".to_string()], + }, + Profile { + id: "p-xyz".to_string(), + name: "Sampingan".to_string(), + service_ids: vec!["mysql".to_string(), "minio".to_string()], + }, + ], + active_profile_id: Some("p-abc".to_string()), + ..Default::default() + }; + + let migrated = migrate(original.clone()); + assert_eq!(migrated.version, 2); + assert_eq!(migrated.profiles.len(), 2); + assert_eq!(migrated.active_profile_id.as_deref(), Some("p-abc")); + assert_eq!(migrated.selected_service_ids, vec!["mongodb"]); + } + + #[test] + fn test_migrate_invalid_active_falls_back_to_first() { + // active_profile_id menunjuk profil yang tak ada → fallback ke profil pertama. + let cfg = ConfigState { + version: 2, + profiles: vec![Profile { + id: "p-1".to_string(), + name: "Satu".to_string(), + service_ids: vec!["redis".to_string()], + }], + active_profile_id: Some("hilang".to_string()), + ..Default::default() + }; + let cfg = migrate(cfg); + assert_eq!(cfg.active_profile_id.as_deref(), Some("p-1")); + assert_eq!(cfg.selected_service_ids, vec!["redis"]); + } + + #[test] + fn test_empty_config_gets_default_profile() { + // Config kosong total → default 1 profil aktif. + let cfg: ConfigState = serde_json::from_str("{}").expect("parse gagal"); + let cfg = migrate(cfg); + assert_eq!(cfg.profiles.len(), 1); + assert_eq!(cfg.active_profile_id.as_deref(), Some("default")); + } } From 6185a4a95b3cd38e6b277889e88dd8f1eddbcb68 Mon Sep 17 00:00:00 2001 From: Irfan Hardiyanto <52022757+devhardiyanto@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:09:50 +0700 Subject: [PATCH 4/6] feat(frontend): composable manajemen profil (CRUD + apply) Perluas useConfig: interface Profile, profiles/activeProfileId, CRUD (create/rename/delete + guard min 1 profil & fallback), setActiveProfile. Selection selalu proyeksi profil aktif (syncSelection) agar konsisten dgn re-proyeksi backend. useServices.applyProfile set switch uiState + aktifkan. devhardiyanto --- src/composables/useConfig.ts | 81 ++++++++++++++++++++++++++++++++-- src/composables/useServices.ts | 16 ++++++- 2 files changed, 92 insertions(+), 5 deletions(-) diff --git a/src/composables/useConfig.ts b/src/composables/useConfig.ts index 28fa568..a971afc 100644 --- a/src/composables/useConfig.ts +++ b/src/composables/useConfig.ts @@ -1,9 +1,17 @@ import { ref } from 'vue' import { invoke } from '@tauri-apps/api/core' +export interface Profile { + id: string + name: string + serviceIds: string[] +} + export interface ConfigState { version: number selectedServiceIds: string[] + profiles: Profile[] + activeProfileId: string | null lastPhpVersion: string | null lastNodeVersion: string | null watchedPath: string | null @@ -13,8 +21,10 @@ export interface ConfigState { } const DEFAULT_CONFIG: ConfigState = { - version: 1, + version: 2, selectedServiceIds: [], + profiles: [{ id: 'default', name: 'Default', serviceIds: [] }], + activeProfileId: 'default', lastPhpVersion: null, lastNodeVersion: null, watchedPath: null, @@ -24,7 +34,7 @@ const DEFAULT_CONFIG: ConfigState = { } // Singleton state -const config = ref({ ...DEFAULT_CONFIG }) +const config = ref(structuredClone(DEFAULT_CONFIG)) const loaded = ref(false) let saveTimer: ReturnType | null = null @@ -85,9 +95,67 @@ export function useConfig() { scheduleSave() } + // Selection = proyeksi profil aktif. Set selectedServiceIds DAN serviceIds + // profil aktif dalam satu langkah, agar konsisten dgn re-proyeksi backend + // (yang menurunkan selectedServiceIds dari profil aktif saat write). + function syncSelection(ids: string[]): void { + config.value.selectedServiceIds = [...ids] + const activeId = config.value.activeProfileId + if (activeId) { + const p = config.value.profiles.find((pr) => pr.id === activeId) + if (p) p.serviceIds = [...ids] + } + } + function updateSelectedServices(ids: string[]): void { if (!config.value.rememberSession) return - config.value.selectedServiceIds = ids + syncSelection(ids) + scheduleSave() + } + + // Dipakai path toggle (immediate persist, bypass rememberSession) — tetap + // meng-edit profil aktif ("toggle = edit profil aktif"). + function applySelection(ids: string[]): void { + syncSelection(ids) + } + + function createProfile(name: string, serviceIds: string[] = []): string { + const id = crypto.randomUUID() + config.value.profiles.push({ id, name, serviceIds: [...serviceIds] }) + scheduleSave() + return id + } + + function renameProfile(id: string, name: string): void { + const p = config.value.profiles.find((pr) => pr.id === id) + if (!p) return + p.name = name + scheduleSave() + } + + // Guard: minimal selalu ada 1 profil. Hapus profil aktif → fallback ke profil + // pertama tersisa (selectedServiceIds ikut). Return id aktif baru (utk sinkron UI). + function deleteProfile(id: string): string | null { + if (config.value.profiles.length <= 1) return config.value.activeProfileId + const idx = config.value.profiles.findIndex((pr) => pr.id === id) + if (idx < 0) return config.value.activeProfileId + config.value.profiles.splice(idx, 1) + if (config.value.activeProfileId === id) { + const fallback = config.value.profiles[0] + config.value.activeProfileId = fallback.id + config.value.selectedServiceIds = [...fallback.serviceIds] + } + scheduleSave() + return config.value.activeProfileId + } + + // Set profil aktif + turunkan selectedServiceIds dari serviceIds-nya. Sinkron + // ke uiState (switch) ditangani useServices.applyProfile. + function setActiveProfile(id: string): void { + const p = config.value.profiles.find((pr) => pr.id === id) + if (!p) return + config.value.activeProfileId = id + config.value.selectedServiceIds = [...p.serviceIds] scheduleSave() } @@ -102,7 +170,7 @@ export function useConfig() { } async function reset(): Promise { - const def: ConfigState = { ...DEFAULT_CONFIG } + const def: ConfigState = structuredClone(DEFAULT_CONFIG) config.value = def await invoke('config_write', { config: def }) } @@ -117,6 +185,11 @@ export function useConfig() { setRememberSession, setMinimizeToTray, updateSelectedServices, + applySelection, + createProfile, + renameProfile, + deleteProfile, + setActiveProfile, setLastPhpVersion, setLastNodeVersion, reset, diff --git a/src/composables/useServices.ts b/src/composables/useServices.ts index a3bdcc5..e259f92 100644 --- a/src/composables/useServices.ts +++ b/src/composables/useServices.ts @@ -268,7 +268,8 @@ export function useServices() { .filter((s) => s.selected) .map((s) => s.id) if (useConfig().loaded.value) { - useConfig().config.value.selectedServiceIds = newSelectedIds + // Toggle = edit profil aktif: sinkronkan selection + serviceIds profil aktif. + useConfig().applySelection(newSelectedIds) void useConfig().saveImmediate() } @@ -373,6 +374,18 @@ export function useServices() { } } + // Terapkan profil: set switch (uiState) sesuai serviceIds profil, jadikan aktif, + // dan persist segera agar tray baca selection terbaru. Tidak meng-start/stop + // container yang sedang jalan — hanya mengubah selection (desired state). + function applyProfile(id: string): void { + const cfg = useConfig() + const p = cfg.config.value.profiles.find((pr) => pr.id === id) + if (!p) return + setSelectedIds(p.serviceIds) + cfg.setActiveProfile(id) + void cfg.saveImmediate() + } + function dismissPortConflicts(): void { portConflicts.value = [] } @@ -434,6 +447,7 @@ export function useServices() { syncStatuses, setSelectedIds, reconcileSelectedWithRunning, + applyProfile, start, stop, stopAll, From aaabfece1e645af458a34a02ec45886d949c3dee Mon Sep 17 00:00:00 2001 From: Irfan Hardiyanto <52022757+devhardiyanto@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:12:06 +0700 Subject: [PATCH 5/6] feat(frontend): UI profile switcher + CRUD Dashboard: switcher profil (select) di header Services -> applyProfile. Settings tab Services: daftar profil + badge aktif + rename inline + hapus (guard min 1) + buat profil dari selection saat ini. Konsisten token dark. devhardiyanto --- src/views/Dashboard.vue | 53 ++++++++++ src/views/Settings.vue | 207 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 258 insertions(+), 2 deletions(-) diff --git a/src/views/Dashboard.vue b/src/views/Dashboard.vue index caf5439..a5dbfd2 100644 --- a/src/views/Dashboard.vue +++ b/src/views/Dashboard.vue @@ -86,12 +86,21 @@ const { toggle, setSelectedIds, reconcileSelectedWithRunning, + applyProfile, start, stopAll, dismissPortConflicts, startIgnoringConflicts, } = useServices() +const profiles = computed(() => useConfig().config.value.profiles) +const activeProfileId = computed(() => useConfig().config.value.activeProfileId) + +function onProfileChange(e: Event): void { + const id = (e.target as HTMLSelectElement).value + if (id) applyProfile(id) +} + const totalRamWithBaseline = computed(() => { const runningRam = runningIds.value.reduce((acc, id) => { const ui = uiState.value[id] @@ -376,6 +385,17 @@ onUnmounted(() => {
Services {{ runningCount }}/{{ definitions.length }} +
+ profil + +