Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -144,3 +144,5 @@ dist
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*

*workplan*
55 changes: 55 additions & 0 deletions docs/headscale-0.29.x-agentic-workplan.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,49 @@ Introduce first-class policy structures for grants/nodeAttrs while removing lega

---

## Phase 1.5 — Policy architecture stabilisation (de-spaghetti bridge)

## Goal

Reduce coupling before Phase 2/3 feature expansion so grants/nodeAttrs/SSH/auth work lands on maintainable foundations.

## Why this phase now

Phase 1 introduces modern fields, but legacy ACL-centric code paths still concentrate model, serialisation, and UI-side effects in large modules. Without this clean-up, later phases risk compounding complexity and regressions.

## Scope

- Extract pure policy domain operations from UI/runtime concerns.
- Introduce a thin editor/store boundary for Svelte reactivity.
- Keep ACL tabs fully functional during migration.
- Preserve lossless round-trip guarantees from Phase 0/1.

## Subtasks

- [ ] Split `src/lib/common/acl.svelte.ts` into:
- pure policy domain modules (validation/mutations/normalisation)
- UI-facing editor/store wrapper
- persistence adapter(s) for API save/load
- [ ] Add `PolicyBuilder` as first-class implementation (bridge adapter may remain temporarily for ACL UI compatibility).
- [ ] Move toast/loading side-effects out of core model layer into route/component handlers.
- [ ] Add targeted unit tests for extracted pure modules (including invariants and mutation edge cases).
- [ ] Keep old ACL component contracts stable until Phase 2 UI replacement is complete.

## Guardrails

- No behaviour changes to existing ACL workflows.
- No destructive policy writes.
- Keep changes incremental; avoid broad visual/UI rewrites in this phase.

## Acceptance criteria

- Core policy logic is testable without Svelte runtime.
- Model modules are materially smaller and responsibility-separated.
- Existing ACL tabs continue to work unchanged from user perspective.
- CI-equivalent local checks pass before merge.

---

## Phase 2 — Grants and nodeAttrs UX

## Goal
Expand Down Expand Up @@ -303,6 +346,18 @@ Use this per phase:
- Tests run
- Risks/known gaps

### CI parity rule (mandatory)

Before commit/push for any phase, run the same quality gates as `.github/workflows/ci.yml` in Docker and require pass status:

- `svelte-check`
- `vitest`
- `vite build` (+ static route verification where applicable)
- Docker image smoke build/check
- Playwright e2e (dev + docker config where applicable)

Local acceptance should match remote CI expectations; do not push if the CI-equivalent local run is red.

### Commit guidance

- One logical commit per subtask cluster.
Expand Down
59 changes: 42 additions & 17 deletions src/lib/cards/CardListEntry.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -26,24 +26,49 @@
</script>

<div class="grid py-0 grid-cols-12">
<div class="grid grid-cols-12 col-span-12 justify-between {top ? 'items-top' : 'items-center'}" {onclick}>
<!-- Left-aligned title -->
<div class="grid col-span-5 {titleClasses}">
{#if title !== undefined}
{title}
{:else if childTitle !== undefined}
{@render childTitle()}
{/if}
{#if onclick !== undefined}
<button
type="button"
class="grid grid-cols-12 col-span-12 justify-between {top ? 'items-top' : 'items-center'} text-inherit"
onclick={onclick}
>
<!-- Left-aligned title -->
<div class="grid col-span-5 {titleClasses}">
{#if title !== undefined}
{title}
{:else if childTitle !== undefined}
{@render childTitle()}
{/if}
</div>
<!-- Right-aligned slot or value -->
<div class="grid col-span-7 {valueClasses}">
{#if value !== undefined}
{value}
{:else if children !== undefined}
{@render children()}
{/if}
</div>
</button>
{:else}
<div class="grid grid-cols-12 col-span-12 justify-between {top ? 'items-top' : 'items-center'}">
<!-- Left-aligned title -->
<div class="grid col-span-5 {titleClasses}">
{#if title !== undefined}
{title}
{:else if childTitle !== undefined}
{@render childTitle()}
{/if}
</div>
<!-- Right-aligned slot or value -->
<div class="grid col-span-7 {valueClasses}">
{#if value !== undefined}
{value}
{:else if children !== undefined}
{@render children()}
{/if}
</div>
</div>
<!-- Right-aligned slot or value -->
<div class="grid col-span-7 {valueClasses}">
{#if value !== undefined}
{value}
{:else if children !== undefined}
{@render children()}
{/if}
</div>
</div>
{/if}
{#if childBottom}
{@render childBottom()}
{/if}
Expand Down
5 changes: 3 additions & 2 deletions src/lib/cards/node/NodeExpiresAt.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@

let { node, loading = $bindable(false) }: NodeExpiresAtProps = $props()

let diff = $state(getTimeDifference(getTime(node.expiry)));
const getDiff = () => getTimeDifference(getTime(node.expiry))
let diff = $state(getDiff());

onMount(() => {
const interval = setInterval(() => {
diff = getTimeDifference(getTime(node.expiry));
diff = getDiff();
}, 1000);

return () => {
Expand Down
5 changes: 3 additions & 2 deletions src/lib/cards/node/NodeLastSeen.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@
}
let { node }: NodeLastSeenProps = $props()

let lastSeen = $state(getTimeDifferenceMessage(getTime(node.lastSeen)));
const getLastSeen = () => getTimeDifferenceMessage(getTime(node.lastSeen))
let lastSeen = $state(getLastSeen());

onMount(() => {
const interval = setInterval(() => {
lastSeen = getTimeDifferenceMessage(getTime(node.lastSeen));
lastSeen = getLastSeen();
}, 1000);
return () => {
clearInterval(interval);
Expand Down
5 changes: 3 additions & 2 deletions src/lib/cards/preauth/PreAuthKeyDetails.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
}
let { preAuthKey }: PreAuthKeyDetailsProps = $props()

let pakIsExpired = $state(isExpired(preAuthKey))
const getPakIsExpired = () => isExpired(preAuthKey)
let pakIsExpired = $state(getPakIsExpired())
const ownershipLabel = $derived(preAuthKey.user ? 'User:' : 'Tags:')
const ownershipValue = $derived(preAuthKey.user ? preAuthKey.user.name : preAuthKey.aclTags.join(', '))

Expand All @@ -19,7 +20,7 @@

onMount(()=>{
const interval = setInterval(() => {
pakIsExpired = isExpired(preAuthKey)
pakIsExpired = getPakIsExpired()
}, 1000)

return () => {
Expand Down
5 changes: 3 additions & 2 deletions src/lib/cards/user/UserListPreAuthKey.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,16 @@
let { preAuthKey }: UserListPreAuthKeyProps = $props()

const toastStore = getToastStore();
let pakIsExpired = $state(isExpired(preAuthKey))
const getPakIsExpired = () => isExpired(preAuthKey)
let pakIsExpired = $state(getPakIsExpired())

function isExpired(preAuthKey: PreAuthKey): boolean {
return new Date() > new Date(preAuthKey.expiration);
}

onMount(()=>{
const interval = setInterval(() => {
pakIsExpired = isExpired(preAuthKey)
pakIsExpired = getPakIsExpired()
}, 1000)

return () => {
Expand Down
Loading
Loading