4
0

ServiceCardComponent.razor 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466
  1. @inject UpdateServiceUseCase UpdateUseCase
  2. @inject IGetResourceByNameUseCase<Service> GetByNameUseCase
  3. @inject IDeleteResourceUseCase<Service> DeleteUseCase
  4. @inject ICloneResourceUseCase<Service> CloneUseCase
  5. @inject IRenameResourceUseCase<Service> RenameUseCase
  6. @inject NavigationManager Nav
  7. <div class="border border-zinc-800 rounded p-4 bg-zinc-900"
  8. data-testid=@($"service-item-{Service.Name.Replace(" ", "-")}")>
  9. <div class="flex justify-between items-center mb-3">
  10. <NavLink href="@($"resources/services/{Uri.EscapeDataString(Service.Name)}")" class="block"
  11. data-testid=@($"service-item-{Service.Name.Replace(" ", "-")}-link")>
  12. <div class="text-zinc-100 hover:text-emerald-300">
  13. @Service.Name
  14. </div>
  15. </NavLink>
  16. <div class="flex gap-3 text-xs">
  17. @if (!_isEditing)
  18. {
  19. <button class="text-zinc-400 hover:text-zinc-200"
  20. data-testid="edit-service-button"
  21. @onclick="BeginEdit">
  22. Edit
  23. </button>
  24. <button class="text-xs text-blue-400 hover:text-blue-300 transition"
  25. title="Rename service"
  26. data-testid="rename-service-button"
  27. @onclick="OpenRename">
  28. Rename
  29. </button>
  30. <button
  31. class="text-xs text-emerald-400 hover:text-emerald-300 transition"
  32. title="Clone service"
  33. data-testid="clone-service-button"
  34. @onclick="OpenClone">
  35. Clone
  36. </button>
  37. <button
  38. class="text-xs text-red-400 hover:text-red-300 transition"
  39. title="Delete server"
  40. data-testid="delete-service-button"
  41. @onclick="ConfirmDelete">
  42. Delete
  43. </button>
  44. }
  45. else
  46. {
  47. <button class="text-emerald-400 hover:text-emerald-300"
  48. data-testid="save-service-button"
  49. @onclick="Save">
  50. Save
  51. </button>
  52. <button class="text-zinc-500 hover:text-zinc-300"
  53. data-testid="cancel-service-button"
  54. @onclick="Cancel">
  55. Cancel
  56. </button>
  57. }
  58. </div>
  59. </div>
  60. <div class="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
  61. <!-- IP -->
  62. <div data-testid="service-ip-section">
  63. <div class="text-zinc-400 mb-1">IP</div>
  64. @if (_isEditing)
  65. {
  66. <input
  67. data-testid="service-ip-input"
  68. class="w-full px-3 py-2 rounded-md
  69. bg-zinc-800 text-zinc-100
  70. border border-zinc-600"
  71. @bind="_edit.Ip"/>
  72. }
  73. else if (!string.IsNullOrWhiteSpace(Service.Network?.Ip))
  74. {
  75. <div class="text-zinc-300"
  76. data-testid="service-ip-value">
  77. @Service.Network!.Ip
  78. </div>
  79. }
  80. </div>
  81. <!-- Port -->
  82. <div data-testid="service-port-section">
  83. <div class="text-zinc-400 mb-1">Port</div>
  84. @if (_isEditing)
  85. {
  86. <input type="number"
  87. data-testid="service-port-input"
  88. @bind="_edit.Port"/>
  89. }
  90. else if (Service.Network?.Port.HasValue == true)
  91. {
  92. var href = GetBrowsableHref();
  93. if (href is not null)
  94. {
  95. <a href="@href"
  96. data-testid="service-port-value"
  97. target="_blank"
  98. rel="noopener noreferrer"
  99. class="text-emerald-400 hover:text-emerald-300 hover:underline transition">
  100. @Service.Network.Port
  101. </a>
  102. }
  103. else
  104. {
  105. <div class="text-zinc-300" data-testid="service-port-value">
  106. @Service.Network.Port
  107. </div>
  108. }
  109. }
  110. </div>
  111. <!-- Protocol -->
  112. <div data-testid="service-protocol-section">
  113. <div class="text-zinc-400 mb-1">Protocol</div>
  114. @if (_isEditing)
  115. {
  116. <input
  117. data-testid="service-protocol-input"
  118. class="w-full px-3 py-2 rounded-md bg-zinc-800 text-zinc-100 border border-zinc-600"
  119. @bind="_edit.Protocol"/>
  120. }
  121. else if (!string.IsNullOrWhiteSpace(Service.Network?.Protocol))
  122. {
  123. <div class="text-zinc-300"
  124. data-testid="service-protocol-value">
  125. @Service.Network!.Protocol
  126. </div>
  127. }
  128. </div>
  129. <!-- URL -->
  130. <div data-testid="service-url-section">
  131. <div class="text-zinc-400 mb-1">URL</div>
  132. @if (_isEditing)
  133. {
  134. <input
  135. data-testid="service-url-input"
  136. class="w-full px-3 py-2 rounded-md bg-zinc-800 text-zinc-100 border border-zinc-600"
  137. @bind="_edit.Url"/>
  138. }
  139. else if (!string.IsNullOrWhiteSpace(Service.Network?.Url))
  140. {
  141. <a href="@Service.Network!.Url"
  142. data-testid="service-url-value"
  143. target="_blank"
  144. rel="noopener noreferrer"
  145. class="text-emerald-400 hover:underline break-all">
  146. @Service.Network.Url
  147. </a>
  148. }
  149. </div>
  150. <!-- Runs On -->
  151. <div data-testid="service-runson-section">
  152. <div class="text-zinc-400 mb-1">
  153. Runs On
  154. <button
  155. data-testid="service-runson-button"
  156. class="hover:text-emerald-400 pr-4"
  157. title="Add Runs On"
  158. @onclick="() => _selectParentOpen = true">
  159. @("+")
  160. </button>
  161. </div>
  162. @if (_isEditing)
  163. {
  164. @if (Service.RunsOn?.Count > 0)
  165. {
  166. @foreach(var parent in Service.RunsOn)
  167. {
  168. <button
  169. class="hover:text-emerald-400"
  170. title="Edit Runs On"
  171. @onclick="() => _selectParentOpen = true">
  172. @($"{parent}")
  173. </button>
  174. <button
  175. class="text-red-400 hover:text-red-300 pr-4"
  176. title="Remove"
  177. @onclick="() => HandleParentDeleted(parent)">
  178. @($"✕")
  179. </button>
  180. }
  181. }
  182. }
  183. else if (Service.RunsOn?.Count > 0)
  184. {
  185. @foreach(var parent in Service.RunsOn)
  186. {
  187. <NavLink href="@($"resources/systems/{Uri.EscapeDataString(parent)}")"
  188. data-testid="service-runson-link"
  189. class="text-emerald-400 pr-4">
  190. @parent
  191. </NavLink>
  192. }
  193. }
  194. </div>
  195. <ResourceTagEditor Resource="Service"
  196. TestIdPrefix="service" />
  197. <ResourceLabelEditor Resource="Service"
  198. TestIdPrefix="service" />
  199. <div class="md:col-span-2">
  200. <div class="text-zinc-400 mb-1">Notes</div>
  201. @if (_isEditing)
  202. {
  203. <MarkdownEditor
  204. @bind-Value="_edit.Notes"
  205. ShowActionButtons="false"
  206. TestIdPrefix="service-notes-editor" />
  207. }
  208. else
  209. {
  210. <MarkdownViewer
  211. Value="@Service.Notes"
  212. ShowEditButton="false"
  213. TestIdPrefix="service-notes-viewer" />
  214. }
  215. </div>
  216. </div>
  217. </div>
  218. <SystemSelectionModal
  219. IsOpen="@_selectParentOpen"
  220. IsOpenChanged="v => _selectParentOpen = v"
  221. Title="Select a parent"
  222. Value="@SelectedParentName"
  223. OnAccept="HandleParentSelected"/>
  224. <ConfirmModal
  225. IsOpen="_confirmDeleteOpen"
  226. IsOpenChanged="v => _confirmDeleteOpen = v"
  227. Title="Delete service"
  228. ConfirmText="Delete"
  229. ConfirmClass="bg-red-600 hover:bg-red-500"
  230. OnConfirm="DeleteServer" TestIdPrefix="service-delete">
  231. Are you sure you want to delete <strong>@Service.Name</strong>?
  232. </ConfirmModal>
  233. <StringValueModal
  234. IsOpen="_cloneOpen"
  235. IsOpenChanged="v => _cloneOpen = v"
  236. Title="Clone service"
  237. Description="Enter a name for the cloned service"
  238. Label="New service name"
  239. Value="@($"{Service.Name}-copy")"
  240. OnSubmit="HandleCloneSubmit"
  241. TestIdPrefix="service-clone"/>
  242. <StringValueModal
  243. IsOpen="_renameOpen"
  244. IsOpenChanged="v => _renameOpen = v"
  245. Title="Rename service"
  246. Description="Enter a new name for this service"
  247. Label="New service name"
  248. Value="@Service.Name"
  249. OnSubmit="HandleRenameSubmit"
  250. TestIdPrefix="service-rename"/>
  251. @code
  252. {
  253. bool _cloneOpen;
  254. void OpenClone()
  255. {
  256. _cloneOpen = true;
  257. }
  258. async Task HandleCloneSubmit(string newName)
  259. {
  260. await CloneUseCase.ExecuteAsync(Service.Name, newName);
  261. Nav.NavigateTo($"resources/services/{Uri.EscapeDataString(newName)}");
  262. }
  263. }
  264. @code {
  265. [Parameter] [EditorRequired] public Service Service { get; set; } = default!;
  266. [Parameter] public EventCallback<string> OnSave { get; set; }
  267. private bool _isEditing;
  268. private ServiceEditModel _edit = new();
  269. void BeginEdit()
  270. {
  271. _edit = ServiceEditModel.From(Service);
  272. _isEditing = true;
  273. }
  274. async Task Save()
  275. {
  276. _isEditing = false;
  277. await UpdateUseCase.ExecuteAsync(
  278. _edit.Name,
  279. _edit.Ip,
  280. _edit.Port,
  281. _edit.Protocol,
  282. _edit.Url,
  283. _edit.RunsOn,
  284. _edit.Notes
  285. );
  286. await OnSave.InvokeAsync(Service.Name);
  287. }
  288. void Cancel()
  289. {
  290. _isEditing = false;
  291. }
  292. bool _selectParentOpen;
  293. string? SelectedParentName;
  294. async Task HandleParentSelected(string? name)
  295. {
  296. SelectedParentName = name;
  297. var runsOn = (_isEditing ? _edit.RunsOn : Service.RunsOn) ?? new List<string>();
  298. runsOn = runsOn.Where(x => !string.IsNullOrWhiteSpace(x)).ToList();
  299. if (!string.IsNullOrWhiteSpace(name) && !runsOn.Contains(name))
  300. runsOn.Add(name);
  301. var ip = _isEditing ? _edit.Ip : Service.Network?.Ip;
  302. var port = _isEditing ? _edit.Port : Service.Network?.Port;
  303. var protocol = _isEditing ? _edit.Protocol : Service.Network?.Protocol;
  304. var url = _isEditing ? _edit.Url : Service.Network?.Url;
  305. var notes = _isEditing ? _edit.Notes : Service.Notes;
  306. await UpdateUseCase.ExecuteAsync(Service.Name, ip, port, protocol, url, runsOn, notes);
  307. // Refresh service from backend (optional, but if you do it, DO NOT nuke the edit buffer)
  308. Service = await GetByNameUseCase.ExecuteAsync(Service.Name);
  309. if (_isEditing)
  310. {
  311. // keep whatever the user typed; just sync RunsOn
  312. _edit.RunsOn = runsOn;
  313. }
  314. else
  315. {
  316. _edit = ServiceEditModel.From(Service);
  317. }
  318. }
  319. async Task HandleParentDeleted(string? name)
  320. {
  321. if (string.IsNullOrWhiteSpace(name))
  322. return;
  323. SelectedParentName = name;
  324. var runsOn = (_isEditing ? _edit.RunsOn : Service.RunsOn) ?? new List<string>();
  325. runsOn = runsOn.Where(x => !string.IsNullOrWhiteSpace(x) && x != name).ToList();
  326. var ip = _isEditing ? _edit.Ip : Service.Network?.Ip;
  327. var port = _isEditing ? _edit.Port : Service.Network?.Port;
  328. var protocol = _isEditing ? _edit.Protocol : Service.Network?.Protocol;
  329. var url = _isEditing ? _edit.Url : Service.Network?.Url;
  330. var notes = _isEditing ? _edit.Notes : Service.Notes;
  331. await UpdateUseCase.ExecuteAsync(Service.Name, ip, port, protocol, url, runsOn, notes);
  332. Service = await GetByNameUseCase.ExecuteAsync(Service.Name);
  333. if (_isEditing)
  334. _edit.RunsOn = runsOn;
  335. else
  336. _edit = ServiceEditModel.From(Service);
  337. }
  338. }
  339. @code {
  340. private bool _confirmDeleteOpen;
  341. [Parameter] public EventCallback<string> OnDeleted { get; set; }
  342. void ConfirmDelete()
  343. {
  344. _confirmDeleteOpen = true;
  345. }
  346. async Task DeleteServer()
  347. {
  348. _confirmDeleteOpen = false;
  349. await DeleteUseCase.ExecuteAsync(Service.Name);
  350. if (OnDeleted.HasDelegate)
  351. await OnDeleted.InvokeAsync(Service.Name);
  352. }
  353. }
  354. @code
  355. {
  356. bool _renameOpen;
  357. void OpenRename()
  358. {
  359. _renameOpen = true;
  360. }
  361. async Task HandleRenameSubmit(string newName)
  362. {
  363. await RenameUseCase.ExecuteAsync(Service.Name, newName);
  364. Nav.NavigateTo($"resources/services/{Uri.EscapeDataString(newName)}");
  365. }
  366. private string? GetBrowsableHref()
  367. {
  368. var ip = Service.Network?.Ip;
  369. var port = Service.Network?.Port;
  370. if (string.IsNullOrWhiteSpace(ip) || port is null)
  371. return null;
  372. var proto = Service.Network?.Protocol?.Trim().ToLowerInvariant();
  373. var scheme = proto switch
  374. {
  375. "https" => "https",
  376. "http" => "http",
  377. _ => "http"
  378. };
  379. // Build a correct absolute URL
  380. var ub = new UriBuilder(scheme, ip) { Port = port.Value };
  381. return ub.Uri.ToString();
  382. }
  383. }