DEVELOPMENT ENVIRONMENT

~liljamo/emerwen-web

4e555b1e3309881de4969794eff19e5275430069 — Jonni Liljamo 11 hours ago 14e0729 master
feat: UI for new/patch/delete worker/target
M README.md => README.md +1 -0
@@ 2,3 2,4 @@

## Assets
- https://www.svgrepo.com/svg/481486/sheep - Public Domain
- https://www.svgrepo.com/collection/solar-broken-line-icons - CC Attribution

M go.mod => go.mod +2 -2
@@ 3,7 3,7 @@ module git.src.quest/~liljamo/emerwen-web
go 1.23.3

require (
	git.src.quest/~liljamo/emerwen-proto v0.0.0-20241118080237-06cafd5cb729
	git.src.quest/~liljamo/emerwen-proto v0.0.0-20241120090258-63eb26154b46
	github.com/a-h/templ v0.2.793
	github.com/alexedwards/scs/v2 v2.8.0
	github.com/coreos/go-oidc/v3 v3.11.0


@@ 12,6 12,7 @@ require (
	golang.org/x/oauth2 v0.24.0
	golang.org/x/sync v0.8.0
	google.golang.org/grpc v1.68.0
	google.golang.org/protobuf v1.35.2
)

require (


@@ 44,6 45,5 @@ require (
	golang.org/x/sys v0.25.0 // indirect
	golang.org/x/text v0.18.0 // indirect
	google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
	google.golang.org/protobuf v1.35.2 // indirect
	gopkg.in/yaml.v3 v3.0.1 // indirect
)

M go.sum => go.sum +2 -2
@@ 1,5 1,5 @@
git.src.quest/~liljamo/emerwen-proto v0.0.0-20241118080237-06cafd5cb729 h1:Zka776qZfycsq3f6VbDrbou3egXl2mCRFp0JkfH1VyY=
git.src.quest/~liljamo/emerwen-proto v0.0.0-20241118080237-06cafd5cb729/go.mod h1:TZkTqP3/rDTcJDTDU61QvV+4+TsZIvW0lt4hOiYCrr0=
git.src.quest/~liljamo/emerwen-proto v0.0.0-20241120090258-63eb26154b46 h1:+kCaPHVSpiXPVB5GzVP68LrgJ9HvYE7p7tnNUHjpGfM=
git.src.quest/~liljamo/emerwen-proto v0.0.0-20241120090258-63eb26154b46/go.mod h1:TZkTqP3/rDTcJDTDU61QvV+4+TsZIvW0lt4hOiYCrr0=
github.com/a-h/templ v0.2.793 h1:Io+/ocnfGWYO4VHdR0zBbf39PQlnzVCVVD+wEEs6/qY=
github.com/a-h/templ v0.2.793/go.mod h1:lq48JXoUvuQrU0VThrK31yFwdRjTCnIE5bcPCM9IP1w=
github.com/alexedwards/scs/v2 v2.8.0 h1:h31yUYoycPuL0zt14c0gd+oqxfRwIj6SOjHdKRZxhEw=

M internal/components/components.templ => internal/components/components.templ +59 -7
@@ 16,19 16,71 @@ script toggleKeyVisibility(id string) {
}

templ eyeToggle(t string) {
	<div class="border p-1 flex items-center justify-center">
		<input class="absolute w-6 h-6 appearance-none cursor-pointer" type="checkbox"
	<div class="border p-1 flex items-center justify-center w-fit h-fit bg-slate-50 hover:bg-slate-200 transition-colors">
		<input class="absolute w-[29px] h-[29px] appearance-none cursor-pointer" type="checkbox"
			onClick={ toggleKeyVisibility(t) }
		/>
		<div>
			<svg id={ fmt.Sprintf("eye_visible_%s", t) } class="hidden w-5 h-5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
				<path d="M15.0007 12C15.0007 13.6569 13.6576 15 12.0007 15C10.3439 15 9.00073 13.6569 9.00073 12C9.00073 10.3431 10.3439 9 12.0007 9C13.6576 9 15.0007 10.3431 15.0007 12Z" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
				<path d="M12.0012 5C7.52354 5 3.73326 7.94288 2.45898 12C3.73324 16.0571 7.52354 19 12.0012 19C16.4788 19 20.2691 16.0571 21.5434 12C20.2691 7.94291 16.4788 5 12.0012 5Z" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
			<svg id={ fmt.Sprintf("eye_visible_%s", t) } class="hidden w-5 h-5 stroke-black" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
					<path d="M9 4.45962C9.91153 4.16968 10.9104 4 12 4C16.1819 4 19.028 6.49956 20.7251 8.70433C21.575 9.80853 22 10.3606 22 12C22 13.6394 21.575 14.1915 20.7251 15.2957C19.028 17.5004 16.1819 20 12 20C7.81811 20 4.97196 17.5004 3.27489 15.2957C2.42496 14.1915 2 13.6394 2 12C2 10.3606 2.42496 9.80853 3.27489 8.70433C3.75612 8.07914 4.32973 7.43025 5 6.82137" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
					<path d="M15 12C15 13.6569 13.6569 15 12 15C10.3431 15 9 13.6569 9 12C9 10.3431 10.3431 9 12 9C13.6569 9 15 10.3431 15 12Z" stroke-width="1.5"/>
			</svg>

			<svg id={ fmt.Sprintf("eye_hidden_%s", t) } class="w-5 h-5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
				<path d="M2.99902 3L20.999 21M9.8433 9.91364C9.32066 10.4536 8.99902 11.1892 8.99902 12C8.99902 13.6569 10.3422 15 11.999 15C12.8215 15 13.5667 14.669 14.1086 14.133M6.49902 6.64715C4.59972 7.90034 3.15305 9.78394 2.45703 12C3.73128 16.0571 7.52159 19 11.9992 19C13.9881 19 15.8414 18.4194 17.3988 17.4184M10.999 5.04939C11.328 5.01673 11.6617 5 11.9992 5C16.4769 5 20.2672 7.94291 21.5414 12C21.2607 12.894 20.8577 13.7338 20.3522 14.5" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
			<svg id={ fmt.Sprintf("eye_hidden_%s", t) } class="w-5 h-5 fill-black stroke-none" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
				<path d="M2.68936 6.70456C2.52619 6.32384 2.08528 6.14747 1.70456 6.31064C1.32384 6.47381 1.14747 6.91472 1.31064 7.29544L2.68936 6.70456ZM15.5872 13.3287L15.3125 12.6308L15.5872 13.3287ZM9.04145 13.7377C9.26736 13.3906 9.16904 12.926 8.82185 12.7001C8.47466 12.4742 8.01008 12.5725 7.78417 12.9197L9.04145 13.7377ZM6.37136 15.091C6.14545 15.4381 6.24377 15.9027 6.59096 16.1286C6.93815 16.3545 7.40273 16.2562 7.62864 15.909L6.37136 15.091ZM22.6894 7.29544C22.8525 6.91472 22.6762 6.47381 22.2954 6.31064C21.9147 6.14747 21.4738 6.32384 21.3106 6.70456L22.6894 7.29544ZM19 11.1288L18.4867 10.582V10.582L19 11.1288ZM19.9697 13.1592C20.2626 13.4521 20.7374 13.4521 21.0303 13.1592C21.3232 12.8663 21.3232 12.3914 21.0303 12.0985L19.9697 13.1592ZM11.25 16.5C11.25 16.9142 11.5858 17.25 12 17.25C12.4142 17.25 12.75 16.9142 12.75 16.5H11.25ZM16.3714 15.909C16.5973 16.2562 17.0619 16.3545 17.409 16.1286C17.7562 15.9027 17.8545 15.4381 17.6286 15.091L16.3714 15.909ZM5.53033 11.6592C5.82322 11.3663 5.82322 10.8914 5.53033 10.5985C5.23744 10.3056 4.76256 10.3056 4.46967 10.5985L5.53033 11.6592ZM2.96967 12.0985C2.67678 12.3914 2.67678 12.8663 2.96967 13.1592C3.26256 13.4521 3.73744 13.4521 4.03033 13.1592L2.96967 12.0985ZM12 13.25C8.77611 13.25 6.46133 11.6446 4.9246 9.98966C4.15645 9.16243 3.59325 8.33284 3.22259 7.71014C3.03769 7.3995 2.90187 7.14232 2.8134 6.96537C2.76919 6.87696 2.73689 6.80875 2.71627 6.76411C2.70597 6.7418 2.69859 6.7254 2.69411 6.71533C2.69187 6.7103 2.69036 6.70684 2.68957 6.70503C2.68917 6.70413 2.68896 6.70363 2.68892 6.70355C2.68891 6.70351 2.68893 6.70357 2.68901 6.70374C2.68904 6.70382 2.68913 6.70403 2.68915 6.70407C2.68925 6.7043 2.68936 6.70456 2 7C1.31064 7.29544 1.31077 7.29575 1.31092 7.29609C1.31098 7.29624 1.31114 7.2966 1.31127 7.2969C1.31152 7.29749 1.31183 7.2982 1.31218 7.299C1.31287 7.30062 1.31376 7.30266 1.31483 7.30512C1.31698 7.31003 1.31988 7.31662 1.32353 7.32483C1.33083 7.34125 1.34115 7.36415 1.35453 7.39311C1.38127 7.45102 1.42026 7.5332 1.47176 7.63619C1.57469 7.84206 1.72794 8.13175 1.93366 8.47736C2.34425 9.16716 2.96855 10.0876 3.8254 11.0103C5.53867 12.8554 8.22389 14.75 12 14.75V13.25ZM15.3125 12.6308C14.3421 13.0128 13.2417 13.25 12 13.25V14.75C13.4382 14.75 14.7246 14.4742 15.8619 14.0266L15.3125 12.6308ZM7.78417 12.9197L6.37136 15.091L7.62864 15.909L9.04145 13.7377L7.78417 12.9197ZM22 7C21.3106 6.70456 21.3107 6.70441 21.3108 6.70427C21.3108 6.70423 21.3108 6.7041 21.3109 6.70402C21.3109 6.70388 21.311 6.70376 21.311 6.70368C21.3111 6.70352 21.3111 6.70349 21.3111 6.7036C21.311 6.7038 21.3107 6.70452 21.3101 6.70576C21.309 6.70823 21.307 6.71275 21.3041 6.71924C21.2983 6.73223 21.2889 6.75309 21.2758 6.78125C21.2495 6.83757 21.2086 6.92295 21.1526 7.03267C21.0406 7.25227 20.869 7.56831 20.6354 7.9432C20.1669 8.69516 19.4563 9.67197 18.4867 10.582L19.5133 11.6757C20.6023 10.6535 21.3917 9.56587 21.9085 8.73646C22.1676 8.32068 22.36 7.9668 22.4889 7.71415C22.5533 7.58775 22.602 7.48643 22.6353 7.41507C22.6519 7.37939 22.6647 7.35118 22.6737 7.33104C22.6782 7.32097 22.6818 7.31292 22.6844 7.30696C22.6857 7.30398 22.6867 7.30153 22.6876 7.2996C22.688 7.29864 22.6883 7.29781 22.6886 7.29712C22.6888 7.29677 22.6889 7.29646 22.689 7.29618C22.6891 7.29604 22.6892 7.29585 22.6892 7.29578C22.6893 7.29561 22.6894 7.29544 22 7ZM18.4867 10.582C17.6277 11.3882 16.5739 12.1343 15.3125 12.6308L15.8619 14.0266C17.3355 13.4466 18.5466 12.583 19.5133 11.6757L18.4867 10.582ZM18.4697 11.6592L19.9697 13.1592L21.0303 12.0985L19.5303 10.5985L18.4697 11.6592ZM11.25 14V16.5H12.75V14H11.25ZM14.9586 13.7377L16.3714 15.909L17.6286 15.091L16.2158 12.9197L14.9586 13.7377ZM4.46967 10.5985L2.96967 12.0985L4.03033 13.1592L5.53033 11.6592L4.46967 10.5985Z"/>
			</svg>
		</div>
	</div>
}

templ smallDetails(summary string, content string) {
	<details class="pl-2 text-sm">
		<summary class="cursor-pointer select-none text-gray-400">{ summary }</summary>
		<div>{ content }</div>
	</details>
}

templ trash(path string, id string, confirm string) {
	<form class="flex items-center">
		<input hidden name="id" value={ id }/>
		<button class="group w-fit h-fit p-1 border bg-slate-50 hover:bg-slate-200 transition-colors" type="submit" hx-delete={ path } hx-confirm={ confirm }>
			<div class="absolute group-hover:animate-lidjump peer-checked:rotate-90">
				<svg class="group-hover:animate-fullrotate h-fit w-5 stroke-rose-600" viewBox="0 0 24 12" fill="none" xmlns="http://www.w3.org/2000/svg">
					<path d="M20.5001 6H3.5" stroke-width="1.5" stroke-linecap="round" />
					<path d="M6.5 6C6.55588 6 6.58382 6 6.60915 5.99936C7.43259 5.97849 8.15902 5.45491 8.43922 4.68032C8.44784 4.65649 8.45667 4.62999 8.47434 4.57697L8.57143 4.28571C8.65431 4.03708 8.69575 3.91276 8.75071 3.8072C8.97001 3.38607 9.37574 3.09364 9.84461 3.01877C9.96213 3 10.0932 3 10.3553 3H13.6447C13.9068 3 14.0379 3 14.1554 3.01877C14.6243 3.09364 15.03 3.38607 15.2493 3.8072C15.3043 3.91276 15.3457 4.03708 15.4286 4.28571L15.5257 4.57697C15.5433 4.62992 15.5522 4.65651 15.5608 4.68032C15.841 5.45491 16.5674 5.97849 17.3909 5.99936C17.4162 6 17.4441 6 17.5 6" stroke-width="1.5" />
				</svg>
			</div>
			<svg class="h-5 w-5 stroke-rose-600" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
				<path d="M9.5 11L10 16" stroke-width="1.5" stroke-linecap="round" />
				<path d="M14.5 11L14 16" stroke-width="1.5" stroke-linecap="round" />
				<path d="M18.3735 15.3991C18.1965 18.054 18.108 19.3815 17.243 20.1907C16.378 21 15.0476 21 12.3868 21H11.6134C8.9526 21 7.6222 21 6.75719 20.1907C5.89218 19.3815 5.80368 18.054 5.62669 15.3991L5.16675 8.5M18.8334 8.5L18.6334 11.5" stroke-width="1.5" stroke-linecap="round" />
			</svg>
		</button>
	</form>
}

templ buttonSubmit() {
	<div>
		<button class="group w-fit h-fit p-1 border bg-slate-50 hover:bg-slate-200 transition-colors" type="submit">
			<svg class="absolute h-5 w-5 stroke-emerald-600 group-hover:stroke-emerald-700 transition-colors" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
				<path d="M8.5 12.5L10.5 14.5L15.5 9.5" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
			</svg>
			<svg class="group-hover:animate-[fullrotate_500ms_ease-in-out] h-5 w-5 stroke-emerald-600 group-hover/submit:stroke-emerald-700 transition-colors" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
				<path d="M7 3.33782C8.47087 2.48697 10.1786 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 10.1786 2.48697 8.47087 3.33782 7" stroke-width="1.5" stroke-linecap="round"/>
			</svg>
		</button>
	</div>
}

templ buttonAdd() {
	<div>
		<button class="group w-fit h-fit p-1 border bg-slate-50 hover:bg-slate-200 transition-colors" type="submit">
			<svg class="absolute h-5 w-5 stroke-emerald-600 group-hover:stroke-emerald-700 transition-colors" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
				<path d="M15 12L12 12M12 12L9 12M12 12L12 9M12 12L12 15" stroke-width="1.5" stroke-linecap="round"/>
			</svg>
			<svg class="group-hover:animate-[fullrotate_500ms_ease-in-out] h-5 w-5 stroke-emerald-600 group-hover/submit:stroke-emerald-700 transition-colors" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
				<path d="M7 3.33782C8.47087 2.48697 10.1786 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12C2 10.1786 2.48697 8.47087 3.33782 7" stroke-width="1.5" stroke-linecap="round"/>
			</svg>
		</button>
	</div>
}

M internal/components/index.templ => internal/components/index.templ +13 -1
@@ 4,8 4,20 @@ templ Index() {
	@Base("emerwen, counting sheep") {
		<div class="flex flex-col gap-2">
			if loggedIn(ctx) {
				<div hx-get="/partials/workers" hx-trigger="load" hx-target="this"></div>
				@newWorker()
				<div hx-get="/partials/workers" hx-trigger="load, refresh-workers from:body" hx-target="this"></div>
			}
		</div>
	}
}

templ newWorker() {
	<details>
		<summary class="select-none">new worker</summary>
		<form class="flex flex-col p-2 gap-1" hx-post="/api/worker">
			<label>name</label>
			<input class="border" type="text" placeholder="..." name="name"/>
			<button class="border p-1" type="submit">add</button>
		</form>
	</details>
}

M internal/components/partials.templ => internal/components/partials.templ +96 -37
@@ 2,15 2,16 @@ package components

import "git.src.quest/~liljamo/emerwen-proto/go/proto/shared"
import "fmt"
import "strings"

func getMethodString(target *shared.Target) string {
	switch target.Method.(type) {
	case *shared.Target_Ping:
		return "Ping"
		return "ping"
	case *shared.Target_Get:
		return "Get"
		return "get"
	default:
		return "fuck"
		return "invalid"
	}
}



@@ 18,45 19,28 @@ templ PartialWorkers(workers []*shared.Worker) {
	<div class="flex flex-col gap-2">
		for _, worker := range workers {
			<div class="flex flex-col bg-slate-100 p-2">
        <div class="flex gap-1">
          ID: { fmt.Sprintf("%d", worker.Id) }
        </div>
        <div class="flex items-center gap-1 border p-1">
          <div>Auth Token: </div>
          <input class="border" disabled type="password" value={ worker.AuthToken }
						id={ fmt.Sprintf("auth_key_%d", worker.Id) }
				<div class="flex items-center gap-1">
					<form class="flex items-center w-full" hx-confirm="update worker name?" hx-patch="/api/worker">
						<input hidden name="id" value={ worker.Id }/>
						<input class="border w-full" type="text" placeholder="..." name="name" value={ worker.Name }/>
						<input type="submit" hidden/>
					</form>
					@trash("/api/worker", worker.Id, "delete worker?")
				</div>
				@smallDetails("id", worker.Id)
        <div class="flex items-center gap-1">
          <div class="min-w-max">auth token: </div>
          <input class="border w-full" disabled type="password" value={ worker.AuthToken }
						id={ fmt.Sprintf("auth_key_%s", worker.Id) }
					/>
					@eyeToggle(fmt.Sprintf("auth_key_%d", worker.Id))
					@eyeToggle(fmt.Sprintf("auth_key_%s", worker.Id))
        </div>
				<details>
					<summary>Targets:</summary>
					<summary class="select-none">targets</summary>
					<div class="flex flex-col gap-1 p-2">
						@newTarget(worker.Id)
						for _, target := range worker.Targets {
							<div class="flex flex-col bg-slate-200 p-2">
								<div class="flex gap-1">
									ID: { fmt.Sprintf("%d", target.Id) }
								</div>
								<div class="flex items-center gap-1 border p-1">
									<div>Addr: </div>
									<input class="border" value={ target.Addr }/>
								</div>
								<div class="flex items-center gap-1 border p-1">
									<div>Interval (ms): </div>
									<input class="border" value={ fmt.Sprint(target.Interval) }/>
								</div>
								<div class="flex flex-col gap-1">
									<div>Method:</div>
									switch m := target.Method.(type) {
									case *shared.Target_Ping:
										<div>Ping</div>
									case *shared.Target_Get:
										<div>Get</div>
										<input class="border" value={ fmt.Sprint(m.Get.OkCodes) }/>
									default:
										<div>fuck</div>
									}
								</div>
							</div>
							@editTarget(target)
						}
					</div>
				</details>


@@ 64,3 48,78 @@ templ PartialWorkers(workers []*shared.Worker) {
		}
	</div>
}

script selectNewTargetMethod(worker_id string) {
	var value = document.getElementById(worker_id + "_new_target_method_select").value;
	var get_options = document.getElementById(worker_id + "_new_target_get_options");

	if (value === "get") {
		get_options.style.display = "block";
	} else {
		get_options.style.display = "none";
	}
}

templ newTarget(worker_id string) {
	<details>
		<summary class="select-none">new target</summary>
		<form class="flex flex-col p-2 gap-1" hx-post="/api/target">
			<input hidden name="worker_id" value={ worker_id }/>
			<label>name</label>
			<input class="border" type="text" placeholder="..." name="name"/>
			<label>addr</label>
			<input class="border" type="text" placeholder="..." name="addr"/>
			<label>interval (ms)</label>
			<input class="border" type="text" placeholder="..." name="interval"/>

			<div>
				<label>method</label>
				<select onchange={ selectNewTargetMethod(worker_id) } id={ worker_id + "_new_target_method_select" } name="method">
					<option value="ping">ping</option>
					<option value="get">get</option>
				</select>
			</div>
			<div id={ worker_id + "_new_target_get_options"} style="display: none;">
				<label>ok codes</label>
				<input class="border" type="text" placeholder="..." name="ok_codes"/>
			</div>

			<button class="border p-1" type="submit">add</button>
		</form>
	</details>
}

templ editTarget(target *shared.Target) {
	<form class="flex flex-col bg-slate-200 p-2" hx-confirm="update target?" hx-patch="/api/target">
		<div class="flex items-center gap-1">
			<input class="border w-full" type="text" placeholder="..." name="name" value={ target.Name }/>
			@trash("/api/target", target.Id, "delete target?")
		</div>
		@smallDetails("id", target.Id)
		<div class="flex items-center gap-1">
			<label>addr:</label>
			<input class="border w-full" type="text" placeholder="..." name="addr" value={ target.Addr }/>
		</div>
		<div class="flex items-center gap-1">
			<label class="min-w-max">interval (ms):</label>
			<input class="border w-full" type="number" placeholder="..." name="interval" value={ fmt.Sprint(target.Interval) }/>
		</div>
		switch m := target.Method.(type) {
		case *shared.Target_Ping:
			<div>method: ping</div>
		case *shared.Target_Get:
			<div>method: get</div>
			<div class="flex items-center gap-1">
				<label class="min-w-max">ok codes:</label>
				<input class="border w-full" type="text" placeholder="..." name="ok_codes"
					value={ strings.Trim(fmt.Sprint(m.Get.OkCodes), "[]") }/>
			</div>
		default:
			<div>invalid</div>
		}
		<div class="flex flex-row-reverse">
			<input hidden name="id" value={ target.Id }/>
			@buttonSubmit()
		</div>
	</form>
}

A internal/handlers/api.go => internal/handlers/api.go +189 -0
@@ 0,0 1,189 @@
/*
 * Copyright (C) 2024 Jonni Liljamo <jonni@liljamo.com>
 *
 * This file is licensed under GPL-3.0-or-later, see NOTICE and LICENSE for
 * more information.
 */

package handlers

import (
	"net/http"
	"strconv"
	"strings"

	"git.src.quest/~liljamo/emerwen-proto/go/proto"
	"git.src.quest/~liljamo/emerwen-proto/go/proto/shared"
	"git.src.quest/~liljamo/emerwen-web/internal/client"
	"github.com/gin-gonic/gin"
)

// APIPostWorker returns a gin handler for the post worker route.
func APIPostWorker(client *client.Client) gin.HandlerFunc {
	return func(c *gin.Context) {
		response, err := client.Client.NewWorker(client.Ctx, &proto.NewWorkerRequest{Name: c.PostForm("name")})
		if err != nil {
			handleClientError(c, err)
			return
		}

		c.Header("HX-Trigger", "refresh-workers")
		c.String(http.StatusOK, response.String())
	}
}

// APIPatchWorker returns a gin handler for the patch worker route.
func APIPatchWorker(client *client.Client) gin.HandlerFunc {
	return func(c *gin.Context) {
		request := proto.PatchWorkerRequest{
			WorkerId: c.PostForm("id"),
		}

		if newName := c.PostForm("name"); newName != "" {
			request.Name = &newName
		}

		response, err := client.Client.PatchWorker(client.Ctx, &request)
		if err != nil {
			handleClientError(c, err)
			return
		}

		c.Header("HX-Trigger", "refresh-workers")
		c.String(http.StatusOK, response.String())
	}
}

// APIDeleteWorker returns a gin handler for the delete worker route.
func APIDeleteWorker(client *client.Client) gin.HandlerFunc {
	return func(c *gin.Context) {
		response, err := client.Client.DeleteWorker(client.Ctx, &proto.DeleteWorkerRequest{WorkerId: c.Query("id")})
		if err != nil {
			handleClientError(c, err)
			return
		}

		c.Header("HX-Trigger", "refresh-workers")
		c.String(http.StatusOK, response.String())
	}
}

// APIPostTarget returns a gin handler for the post target route.
func APIPostTarget(client *client.Client) gin.HandlerFunc {
	return func(c *gin.Context) {
		request := proto.NewTargetRequest{
			WorkerId: c.PostForm("worker_id"),
			Name:     c.PostForm("name"),
			Addr:     c.PostForm("addr"),
		}

		if interval, err := strconv.Atoi(c.PostForm("interval")); err == nil {
			request.Interval = int32(interval)
		} else {
			c.String(http.StatusBadRequest, "Bad interval.")
			return
		}

		switch c.PostForm("method") {
		case "ping":
			request.Method = &proto.NewTargetRequest_Ping{}
		case "get":
			var okCodes []int32

			if okCodesStr := c.PostForm("ok_codes"); okCodesStr != "" {
				for _, codeStr := range strings.Fields(okCodesStr) {
					if len(codeStr) != 3 {
						c.String(http.StatusBadRequest, "Bad code.")
						return
					}

					if code, err := strconv.Atoi(codeStr); err == nil {
						okCodes = append(okCodes, int32(code))
					} else {
						c.String(http.StatusBadRequest, "Bad code.")
						return
					}
				}
			}
			request.Method = &proto.NewTargetRequest_Get{Get: &shared.MethodGET{OkCodes: okCodes}}
		default:
			c.String(http.StatusBadRequest, "Bad method.")
			return
		}

		response, err := client.Client.NewTarget(client.Ctx, &request)
		if err != nil {
			handleClientError(c, err)
			return
		}

		c.Header("HX-Trigger", "refresh-workers")
		c.String(http.StatusOK, response.String())
	}
}

// APIPatchTarget returns a gin handler for the patch target route.
func APIPatchTarget(client *client.Client) gin.HandlerFunc {
	return func(c *gin.Context) {
		request := proto.PatchTargetRequest{
			TargetId: c.PostForm("id"),
		}

		if newName := c.PostForm("name"); newName != "" {
			request.Name = &newName
		}

		if newAddr := c.PostForm("addr"); newAddr != "" {
			request.Addr = &newAddr
		}

		if interval, err := strconv.Atoi(c.PostForm("interval")); err == nil {
			interval := int32(interval)
			request.Interval = &interval
		} else {
			c.String(http.StatusBadRequest, "Bad interval.")
			return
		}

		if okCodesStr := c.PostForm("ok_codes"); okCodesStr != "" {
			var okCodes []int32
			for _, codeStr := range strings.Fields(okCodesStr) {
				if len(codeStr) != 3 {
					c.String(http.StatusBadRequest, "Bad code.")
					return
				}

				if code, err := strconv.Atoi(codeStr); err == nil {
					okCodes = append(okCodes, int32(code))
				} else {
					c.String(http.StatusBadRequest, "Bad code.")
					return
				}
			}
			request.Method = &proto.PatchTargetRequest_Get{Get: &shared.MethodGET{OkCodes: okCodes}}
		}

		response, err := client.Client.PatchTarget(client.Ctx, &request)
		if err != nil {
			handleClientError(c, err)
			return
		}

		c.Header("HX-Trigger", "refresh-workers")
		c.String(http.StatusOK, response.String())
	}
}

// APIDeleteTarget returns a gin handler for the delete target route.
func APIDeleteTarget(client *client.Client) gin.HandlerFunc {
	return func(c *gin.Context) {
		response, err := client.Client.DeleteTarget(client.Ctx, &proto.DeleteTargetRequest{TargetId: c.Query("id")})
		if err != nil {
			handleClientError(c, err)
			return
		}

		c.Header("HX-Trigger", "refresh-workers")
		c.String(http.StatusOK, response.String())
	}
}

M internal/handlers/handlers.go => internal/handlers/handlers.go +28 -0
@@ 7,3 7,31 @@

// Package handlers provides route handlers.
package handlers

import (
	"fmt"
	"log/slog"
	"net/http"

	"github.com/gin-gonic/gin"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
)

func handleClientError(c *gin.Context, err error) {
	if status, ok := status.FromError(err); ok {
		slog.Error("RPC error", slog.String("err", err.Error()))

		if status.Code() == codes.Unavailable {
			c.String(http.StatusInternalServerError, "RPC unavailable.")
			return
		}

		c.String(http.StatusInternalServerError, fmt.Sprintf("RPC error: %s", status.String()))
		return
	}

	slog.Error("Non-RPC error", slog.String("err", err.Error()))

	c.String(http.StatusInternalServerError, "Non-RPC error, see server logs.")
}

M internal/handlers/partials.go => internal/handlers/partials.go +0 -22
@@ 8,37 8,15 @@
package handlers

import (
	"fmt"
	"log/slog"
	"net/http"

	"git.src.quest/~liljamo/emerwen-web/internal/client"
	"git.src.quest/~liljamo/emerwen-web/internal/components"
	"git.src.quest/~liljamo/emerwen-web/internal/renderer"
	"github.com/gin-gonic/gin"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
	"google.golang.org/protobuf/types/known/emptypb"
)

func handleClientError(c *gin.Context, err error) {
	if status, ok := status.FromError(err); ok {
		slog.Error("RPC error", slog.String("err", err.Error()))

		if status.Code() == codes.Unavailable {
			c.String(http.StatusInternalServerError, "RPC unavailable.")
			return
		}

		c.String(http.StatusInternalServerError, fmt.Sprintf("RPC error: %s", status.String()))
		return
	}

	slog.Error("Non-RPC error", slog.String("err", err.Error()))

	c.String(http.StatusInternalServerError, "Non-RPC error, see server logs.")
}

// PartialWorkers returns a gin handler for the partial workers route.
func PartialWorkers(client *client.Client) gin.HandlerFunc {
	return func(c *gin.Context) {

M internal/router/router.go => internal/router/router.go +10 -0
@@ 45,6 45,16 @@ func SetupRouter(l *slog.Logger, a *auth.Auth, sm *scs.SessionManager, client *c
	s := r.Group("/", middlewares.RequireSession(sm))
	{
		s.GET("/partials/workers", handlers.PartialWorkers(client))

		a := r.Group("/api")
		{
			a.POST("/worker", handlers.APIPostWorker(client))
			a.PATCH("/worker", handlers.APIPatchWorker(client))
			a.DELETE("/worker", handlers.APIDeleteWorker(client))
			a.POST("/target", handlers.APIPostTarget(client))
			a.PATCH("/target", handlers.APIPatchTarget(client))
			a.DELETE("/target", handlers.APIDeleteTarget(client))
		}
	}

	return r

M tailwind.config.js => tailwind.config.js +21 -0
@@ 59,10 59,31 @@ module.exports = {
						transform: 'translateX(-10%) translateY(75%)'
					},
				},
				lidjump: {
					'0%': {
						transform: 'translateY(0)',
					},
					'50%': {
						transform: 'translateY(-75%)',
					},
					'100%': {
						transform: 'translateY(0)',
					},
				},
				fullrotate: {
					'0%': {
						transform: 'rotate(0deg)',
					},
					'100%': {
						transform: 'rotate(360deg)',
					},
				},
			},
			animation: {
				sheepspin: 'sheepspin 750ms linear',
				sheepmove: 'sheepmove 750ms linear',
				lidjump: 'lidjump 500ms linear',
				fullrotate: 'fullrotate 500ms linear',
			},
		},
  },