Compare commits

..

13 commits

Author SHA1 Message Date
routerino
a7f79824bf
Update install-openvscode-server.sh 2025-08-30 11:25:52 +10:00
routerino
3c22ed935d
Update version before release 2025-08-23 11:00:38 +10:00
routerino
c48c0a54fb
210 feature request show user names not just usernames (#211)
* fix: display email if username is too short

* feat: added email field to expanded cards
2025-08-23 10:59:46 +10:00
routerino
fbb8b2b968
test before release 2025-07-12 10:02:00 +10:00
routerino
baffa0024c
fix undocumented headscale ui change (#207) 2025-07-12 10:01:05 +10:00
Eloxt Wang
15c4dd9575
Add route removal functionality and fix code formatting (#206)
- Add removeRouteAction function to allow disabling active routes
- Update approveDeviceRoute to accept full routes array instead of single route
2025-07-11 09:00:47 +10:00
routerino
7dd92ab4ad
Fix: CICD Pipeline for release plugin (#201)
* testing-github-piprline

* fix pipeline

* bump version
2025-05-24 12:57:50 +10:00
Chris Bisset
5e97c52303 update readme 2025-05-22 07:38:40 +00:00
Chris Bisset
9516a519b8 update version before publish 2025-05-22 04:24:21 +00:00
Chris Bisset
625647ed19 fix duplicate tags 2025-05-22 04:23:57 +00:00
Chris Bisset
907ab6af57 remove old route class 2025-05-22 04:13:58 +00:00
Chris Bisset
adef58f27d fix device route for new API 2025-05-22 04:07:22 +00:00
Chris Bisset
38e0de2696 fix api calls for preauth keys 2025-05-20 00:20:22 +00:00
18 changed files with 143 additions and 208 deletions

View file

@ -10,7 +10,7 @@ jobs:
steps:
- name: Checkout Repository
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Variable Gathering
id: gathervars
@ -33,13 +33,13 @@ jobs:
echo "NOT_PREVIOUSLY_PUBLISHED=$NOT_PREVIOUSLY_PUBLISHED" >> $GITHUB_ENV
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v2
uses: docker/login-action@v3
if: ${{ env.NOT_PREVIOUSLY_PUBLISHED != 0 }}
with:
registry: ghcr.io
@ -47,7 +47,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker Image
uses: docker/build-push-action@v4
uses: docker/build-push-action@v6
if: ${{ env.NOT_PREVIOUSLY_PUBLISHED != 0 }}
with:
build-args: |
@ -60,7 +60,7 @@ jobs:
push: true
- name: Extract build out of docker image
uses: shrink/actions-docker-extract@v2
uses: shrink/actions-docker-extract@v3
if: ${{ env.NOT_PREVIOUSLY_PUBLISHED != 0 }}
id: extract
with:
@ -73,33 +73,14 @@ jobs:
cd "${{ steps.extract.outputs.destination }}"
7z a headscale-ui.zip web
- name: Create Draft Release
id: create_release
uses: actions/create-release@v1
- name: Create Release
uses: softprops/action-gh-release@v2
if: ${{ env.NOT_PREVIOUSLY_PUBLISHED != 0 }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ env.VERSION }}
release_name: headscale-ui
draft: true
prerelease: false
- name: upload asset to releases
uses: actions/upload-release-asset@v1.0.1
if: ${{ env.NOT_PREVIOUSLY_PUBLISHED != 0 }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ${{ steps.extract.outputs.destination }}/headscale-ui.zip
asset_name: headscale-ui.zip
asset_content_type: application/zip
- name: publish release
uses: eregon/publish-release@v1
if: ${{ env.NOT_PREVIOUSLY_PUBLISHED != 0 }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
release_id: ${{ steps.create_release.outputs.id }}
name: headscale-ui
files: ${{ steps.extract.outputs.destination }}/headscale-ui.zip
generate_release_notes: true
make_latest: true

View file

@ -91,6 +91,7 @@ See [Other Configurations](/documentation/configuration.md) for further proxy ex
The following versions correspond to the appropriate headscale version
| Headscale Version | HS-UI Version |
|-------------------|---------------|
| 26+ | 2025-05-22+ |
| 25+ | 2025-03-14+ |
| 24+ | 2025-01-20+ |
| 23+ | 2024-10-01+ |

View file

@ -1,5 +1,5 @@
# script variables
OPENVSCODE_VERSION="1.98.0"
OPENVSCODE_VERSION="1.103.1"
OPENVSCODE_URL="https://github.com/gitpod-io/openvscode-server/releases/download/openvscode-server-v$OPENVSCODE_VERSION/openvscode-server-v$OPENVSCODE_VERSION-linux-x64.tar.gz"
OPENVSCODE_RELEASE="openvscode-server-v$OPENVSCODE_VERSION-linux-x64"

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "headscale-ui",
"version": "2025.03.14",
"version": "2025.07.12",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "headscale-ui",
"version": "2025.03.14",
"version": "2025.07.12",
"devDependencies": {
"@sveltejs/adapter-auto": "^4",
"@sveltejs/adapter-static": "^3",

View file

@ -1,6 +1,6 @@
{
"name": "headscale-ui",
"version": "2025.03.21",
"version": "2025.08.23",
"scripts": {
"dev": "vite dev --port 8080 --host 0.0.0.0",
"build": "vite build",
@ -33,4 +33,4 @@
"typescript": "^5"
},
"type": "module"
}
}

View file

@ -329,7 +329,7 @@
return apiKeys;
}
export async function getPreauthKeys(userName: string): Promise<PreAuthKey[]> {
export async function getPreauthKeys(userID: string): Promise<PreAuthKey[]> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
@ -341,7 +341,7 @@
let headscalePreAuthKey = [new PreAuthKey()];
let headscalePreAuthKeyResponse: Response = new Response();
await fetch(headscaleURL + endpointURL + '?user=' + userName, {
await fetch(headscaleURL + endpointURL + '?user=' + userID, {
method: 'GET',
headers: {
Accept: 'application/json',
@ -367,7 +367,7 @@
return headscalePreAuthKey;
}
export async function newPreAuthKey(userName: string, expiry: string, reusable: boolean, ephemeral: boolean): Promise<any> {
export async function newPreAuthKey(userID: string, expiry: string, reusable: boolean, ephemeral: boolean): Promise<any> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
@ -381,7 +381,7 @@
Authorization: `Bearer ${headscaleAPIKey}`
},
body: JSON.stringify({
user: userName,
user: userID,
expiration: expiry,
reusable: reusable,
ephemeral: ephemeral
@ -401,7 +401,7 @@
});
}
export async function removePreAuthKey(userName: string, preAuthKey: string): Promise<any> {
export async function removePreAuthKey(userID: string, preAuthKey: string): Promise<any> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
@ -416,7 +416,7 @@
Authorization: `Bearer ${headscaleAPIKey}`
},
body: JSON.stringify({
user: userName,
user: userID,
key: preAuthKey
})
})
@ -464,7 +464,7 @@
});
}
export async function moveDevice(deviceID: string, user: string): Promise<any> {
export async function moveDevice(deviceID: string, userID: string): Promise<any> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
@ -480,7 +480,7 @@
Authorization: `Bearer ${headscaleAPIKey}`
},
body: JSON.stringify({
user: user
user: parseInt(userID)
})
})
.then((response) => {

View file

@ -7,7 +7,10 @@ export class Device {
public forcedTags: string[] = [];
public validTags: string[] = [];
public invalidTags: string[] = [];
public user: { name: string } = { name: '' };
public approvedRoutes: string[] = [];
public availableRoutes: string[] = [];
public subnetRoutes: string[] = [];
public user: User = new User();
public online?: boolean;
public constructor(init?: Partial<Device>) {
@ -18,19 +21,7 @@ export class Device {
export class ACL {
public groups: { [key: string]: [string] } = {};
public constructor(init?: Partial<Route>) {
Object.assign(this, init);
}
}
export class Route {
// current (hs 18+) method of handling a route
advertised: boolean = true;
prefix: string = '';
enabled: boolean = false;
id: number = 0;
public constructor(init?: Partial<Route>) {
public constructor(init?: Partial<ACL>) {
Object.assign(this, init);
}
}
@ -42,7 +33,7 @@ export class APIKey {
createdAt: string = '';
lastSeen: string = '';
public constructor(init?: Partial<Route>) {
public constructor(init?: Partial<APIKey>) {
Object.assign(this, init);
}
}
@ -65,6 +56,7 @@ export class PreAuthKey {
export class User {
public id: string = '';
public name: string = '';
public email: string = '';
public createdAt: string = '';
public constructor(init?: Partial<User>) {
Object.assign(this, init);

View file

@ -65,7 +65,7 @@
<label class="block text-secondary text-sm font-bold mb-2" for="select">Select User</label>
<select class="card-select mr-3" required bind:value={selectedUser}>
{#each $userStore as user}
<option>{user.name}</option>
<option>{user.name.length > 1 ? user.name : user.email}</option>
{/each}
</select>
</div>

View file

@ -1,67 +1,18 @@
<script lang="ts">
import { getDeviceRoutes, modifyDeviceRoutes } from './DeviceRoutesAPI.svelte';
import { Device, Route } from '$lib/common/classes';
import { onMount } from 'svelte';
import { alertStore } from '$lib/common/stores';
import { Device } from '$lib/common/classes';
import DeviceRoute from './DeviceRoutes/DeviceRoute.svelte';
export let device = new Device();
let routesList: Route[] = [];
let routeID = 0;
onMount(async () => {
getDeviceRoutesAction();
});
function getDeviceRoutesAction() {
getDeviceRoutes(device.id)
.then((routes) => {
routesList = routes;
})
.catch((error) => {
$alertStore = error;
});
}
function modifyDeviceRoutesAction() {
modifyDeviceRoutes(device.id, routesList, routeID)
.then((response) => {
getDeviceRoutesAction();
})
.catch((error) => {
$alertStore = error;
});
}
</script>
<th>Device Routes</th>
<td
><ul class="list-disc list-inside">
{#each routesList as route, index}
{#each device.availableRoutes as route}
<li>
{route.prefix}
{#if route.enabled}
<button
on:click={() => {
routesList[index].enabled = false;
routeID = route.id;
modifyDeviceRoutesAction();
}}
type="button"
class="btn btn-xs tooltip capitalize bg-success text-success-content mx-1"
data-tip="press to disable route">active</button
>
{:else}
<button
on:click={() => {
routesList[index].enabled = true;
routeID = route.id
modifyDeviceRoutesAction();
}}
type="button"
class="btn btn-xs tooltip capitalize bg-secondary text-secondary-content mx-1"
data-tip="press to enable route">pending</button
>
{/if}
<DeviceRoute {route} {device}></DeviceRoute>
</li>
{/each}
</ul></td

View file

@ -0,0 +1,60 @@
<script>
import { getDevices } from '$lib/common/apiFunctions.svelte';
import { Device } from '$lib/common/classes';
import { alertStore } from '$lib/common/stores';
import { approveDeviceRoute } from './DeviceRouteAPI.svelte';
export let route = '';
export let device = new Device();
let routeDisabled = false;
function approveRouteAction() {
approveDeviceRoute(device.id, [...device.approvedRoutes, route])
.then(() => {
// refresh users after editing
getDevices();
})
.catch((error) => {
$alertStore = error;
});
}
function removeRouteAction() {
approveDeviceRoute(device.id, device.approvedRoutes.filter((r) => r !== route))
.then(() => {
// refresh users after editing
getDevices();
})
.catch((error) => {
$alertStore = error;
});
}
</script>
{route}
{#if device.approvedRoutes.includes(route)}
<button
on:click={() => {
routeDisabled = true;
removeRouteAction();
routeDisabled = false;
}}
type="button"
class="btn btn-xs tooltip capitalize bg-success text-success-content mx-1">active</button
>
{:else}
<button
on:click={() => {
routeDisabled = true;
approveRouteAction();
routeDisabled = false;
}}
type="button"
class="btn btn-xs tooltip capitalize bg-secondary text-secondary-content mx-1"
class:disabled={routeDisabled}
data-tip="click to enable route">pending</button
>
{/if}
{#if device.subnetRoutes.includes(route)}
<button type="button" class="btn btn-xs tooltip capitalize bg-secondary text-secondary-content mx-1">subnet</button>
{/if}

View file

@ -0,0 +1,34 @@
<script context="module" lang="ts">
export async function approveDeviceRoute(deviceID: string, routes: string[]): Promise<any> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
let endpointURL = `/api/v1/node/${deviceID}/approve_routes`;
await fetch(headscaleURL + endpointURL, {
method: 'POST',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${headscaleAPIKey}`
},
body: JSON.stringify({
routes: routes
})
})
.then((response) => {
if (response.ok) {
// return the api data
return response;
} else {
return response.text().then((text) => {
throw JSON.parse(text).message;
});
}
})
.catch((error) => {
throw error;
});
}
</script>

View file

@ -1,84 +0,0 @@
<script context="module" lang="ts">
import type { Route } from '$lib/common/classes';
export async function getDeviceRoutes(deviceID: string): Promise<Route[]> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
// endpoint url for getting users
let endpointURL = `/api/v1/node/${deviceID}/routes`;
//returning variables
let headscaleRouteList: Route[] = [];
let headscaleDeviceResponse: Response = new Response();
await fetch(headscaleURL + endpointURL, {
method: 'GET',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${headscaleAPIKey}`
}
})
.then((response) => {
if (response.ok) {
// return the api data
headscaleDeviceResponse = response;
} else {
return response.text().then((text) => {
throw JSON.parse(text).message;
});
}
})
.catch((error) => {
throw error;
});
await headscaleDeviceResponse.json().then((data) => {
headscaleRouteList = data.routes;
});
return headscaleRouteList;
}
export async function modifyDeviceRoutes(deviceID: string, routeList: Route[], routeID: number): Promise<any> {
// variables in local storage
let headscaleURL = localStorage.getItem('headscaleURL') || '';
let headscaleAPIKey = localStorage.getItem('headscaleAPIKey') || '';
let endpointURL = '';
routeList.forEach((route) => {
if (route.id == routeID) {
endpointURL = `/api/v1/routes/${routeID}/`;
if (route.enabled) {
endpointURL += 'enable';
} else {
endpointURL += 'disable';
}
}
});
//returning variables
let headscaleDeviceResponse: Response = new Response();
await fetch(headscaleURL + endpointURL, {
method: 'POST',
headers: {
Accept: 'application/json',
Authorization: `Bearer ${headscaleAPIKey}`
}
})
.then((response) => {
if (response.ok) {
// return the api data
headscaleDeviceResponse = response;
} else {
return response.text().then((text) => {
throw JSON.parse(text).message;
});
}
})
.catch((error) => {
throw error;
});
}
</script>

View file

@ -34,9 +34,5 @@
>
</span>
{/each}
{#each device.validTags as tag}
<span class="mb-1 mr-1 btn btn-xs btn-secondary normal-case">{tag.replace("tag:","")}</span>
{/each}
</div>

View file

@ -6,7 +6,7 @@
export let device = new Device();
let deviceMoving = false;
let selectedUser = device.user.name;
let selectedUser = device.user.id;
function moveDeviceAction() {
moveDevice(device.id, selectedUser)
@ -39,7 +39,7 @@
<form on:submit|preventDefault={moveDeviceAction}>
<select class="card-select mr-3" required bind:value={selectedUser}>
{#each $userStore as user}
<option>{user.name}</option>
<option value={user.id}>{user.name.length > 1 ? user.name : user.email}</option>
{/each}
</select>
<!-- edit accept symbol -->

View file

@ -39,6 +39,10 @@
<div class="overflow-x-auto">
<table class="table table-compact w-full">
<tbody>
<tr>
<th>Email</th>
<td>{user.email}</td>
</tr>
<tr>
<th>User Creation Date</th>
<td>{new Date(user.createdAt)}</td>

View file

@ -22,7 +22,7 @@
}
function getPreauthKeysAction() {
getPreauthKeys(user.name)
getPreauthKeys(user.id)
.then((keys) => {
keyList = keys;
})
@ -107,7 +107,7 @@
<!-- Allow ability to expire if not expired -->
{#if new Date(key.expiration).getTime() > new Date().getTime() && (!key.used || key.reusable)}
<!-- trash symbol -->
<button class="mr-2" on:click={() => {expirePreAuthKeyAction(user.name, key.key)}}
<button class="mr-2" on:click={() => {expirePreAuthKeyAction(user.id, key.key)}}
><svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg></button

View file

@ -15,7 +15,7 @@
function NewPreAuthKeyAction() {
let formattedDate = new Date(expiry).toISOString();
newPreAuthKey(user.name, formattedDate, reusable, ephemeral)
newPreAuthKey(user.id, formattedDate, reusable, ephemeral)
.then(() => {
newPreAuthKeyShow = false;
getPreauthKeysAction();
@ -26,7 +26,7 @@
}
function getPreauthKeysAction() {
getPreauthKeys(user.name)
getPreauthKeys(user.id)
.then((keys) => {
keyList = keys;
})

View file

@ -32,7 +32,7 @@
</script>
{#if !cardEditing}
<span class="font-bold">{user.id}: {user.name}</span>
<span class="font-bold">{user.id}: {user.name.length > 1 ? user.name : user.email}</span>
<!-- edit symbol -->
<button type="button" on:click|stopPropagation={() => editingUser()} class="ml-2"
><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 inline flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">