1 Commits

Author SHA1 Message Date
Mikaël Cluseau c255f1f196 ui rework 2026-06-18 00:19:26 +02:00
8 changed files with 74 additions and 113 deletions
-14
View File
@@ -13,20 +13,6 @@ import (
"novit.tech/direktil/pkg/localconfig" "novit.tech/direktil/pkg/localconfig"
) )
func wsFetchConfig(req *restful.Request, resp *restful.Response) {
cfg, err := readConfig()
if err != nil {
if os.IsNotExist(err) {
wsNotFound(resp)
} else {
wsError(resp, httperr.Internal(err))
}
return
}
resp.WriteEntity(cfg)
}
func wsUploadConfig(req *restful.Request, resp *restful.Response) { func wsUploadConfig(req *restful.Request, resp *restful.Response) {
cfg := &localconfig.Config{} cfg := &localconfig.Config{}
if err := req.ReadEntity(cfg); err != nil { if err := req.ReadEntity(cfg); err != nil {
-3
View File
@@ -73,9 +73,6 @@ func registerWS(rest *restful.Container) {
Doc("Sign a download set")) Doc("Sign a download set"))
// - configs API // - configs API
ws.Route(ws.GET("/config").To(wsFetchConfig).
Produces(mime.JSON, mime.YAML).
Doc("Fetch the current configuration"))
ws.Route(ws.POST("/configs").To(wsUploadConfig). ws.Route(ws.POST("/configs").To(wsUploadConfig).
Consumes(mime.YAML, mime.JSON).Param(ws.BodyParameter("config", "The new full configuration")). Consumes(mime.YAML, mime.JSON).Param(ws.BodyParameter("config", "The new full configuration")).
Produces(mime.JSON).Writes(true). Produces(mime.JSON).Writes(true).
+1 -1
View File
@@ -29,7 +29,7 @@
position:relative; position:relative;
textarea { textarea {
width: 100%; width: 64em;
} }
input[type="file"] { input[type="file"] {
+21 -14
View File
@@ -21,16 +21,21 @@
<div id="app"> <div id="app">
<header> <header>
<span id="logo"><img src="favicon.ico" /> Direktil Local Server</span> <div id="logo">
<span class="utils"> <img src="favicon.ico" />
<span>Direktil Local Server</span>
</div>
<div class="utils">
<span id="login-hdr" v-if="session.token"> <span id="login-hdr" v-if="session.token">
Logged in Logged in
<button class="link" @click="copyText(session.token)">&#x1F5D0;</button> <button class="link" @click="copyText(session.token)">&#x1F5D0;</button>
</span> </span>
<span>server <code>{{ serverVersion || '-----' }}</code></span> <span>server <code>{{ serverVersion || '-----' }}</code></span>
<span>ui <code>{{ uiHash || '-----' }}</code></span> <span>ui <code>{{ uiHash || '-----' }}</code></span>
<span :class="publicState ? 'green' : 'red'">&#x1F5F2;</span> <span :class="publicState ? 'green' : 'red'">&#x1F5F2;</span>
</span> </div>
</header> </header>
<div class="error" v-if="error"> <div class="error" v-if="error">
@@ -41,11 +46,11 @@
<div class="toasts"><div v-for="t in toasts" :class="'toast '+t.kind" @click="dismissToast(t.id)">{{ t.message }}</div></div> <div class="toasts"><div v-for="t in toasts" :class="'toast '+t.kind" @click="dismissToast(t.id)">{{ t.message }}</div></div>
<main class="content" v-if="!publicState"> <template v-if="!publicState">
<p>Not connected.</p> <p>Not connected.</p>
</main> </template>
<main class="content" v-else-if="publicState.Store.New"> <template v-else-if="publicState.Store.New">
<p>Store is new.</p> <p>Store is new.</p>
<p>Option 1: initialize a new store</p> <p>Option 1: initialize a new store</p>
<form @submit="unlockStore"> <form @submit="unlockStore">
@@ -59,17 +64,17 @@
<input type="file" ref="storeUpload" /> <input type="file" ref="storeUpload" />
<input type="submit" value="upload" /> <input type="submit" value="upload" />
</form> </form>
</main> </template>
<main class="content" v-else-if="!publicState.Store.Open"> <template v-else-if="!publicState.Store.Open">
<p>Store is not open.</p> <p>Store is not open.</p>
<form @submit="unlockStore"> <form @submit="unlockStore">
<input type="password" name="passphrase" v-model="forms.store.pass1" required placeholder="Passphrase" /> <input type="password" name="passphrase" v-model="forms.store.pass1" required placeholder="Passphrase" />
<input type="submit" value="unlock" :disabled="!forms.store.pass1" /> <input type="submit" value="unlock" :disabled="!forms.store.pass1" />
</form> </form>
</main> </template>
<main class="content" v-else-if="!state"> <template v-else-if="!state">
<p v-if="!session.token">Not logged in.</p> <p v-if="!session.token">Not logged in.</p>
<p v-else>Invalid token</p> <p v-else>Invalid token</p>
@@ -77,17 +82,18 @@
<input type="password" v-model="forms.store.pass1" required placeholder="Passphrase" /> <input type="password" v-model="forms.store.pass1" required placeholder="Passphrase" />
<input type="submit" value="log in"/> <input type="submit" value="log in"/>
</form> </form>
</main> </template>
<template v-else> <template v-else>
<div class="layout">
<nav class="sidebar"> <nav class="sidebar">
<input type="search" placeholder="Filter" v-model="viewFilter"/> <input type="search" placeholder="Filter" v-model="viewFilter"/>
<p class="nav-section">Admin</p> <p class="nav-section">Admin</p>
<p class="view-links"><button type="button" v-for="v in adminViews" @click="view = v" :class="{selected: isActive(v)}">{{v.title}}</button></p> <p class="view-links"><span v-for="v in adminViews" @click="view = v" :class="{selected: isActive(v)}">{{v.title}}</span></p>
<p class="nav-section" v-if="clusterViews.length">Clusters</p> <p class="nav-section" v-if="clusterViews.length">Clusters</p>
<p class="view-links" v-if="clusterViews.length"><button type="button" v-for="v in clusterViews" @click="view = v" :class="{selected: isActive(v)}">{{v.title}}</button></p> <p class="view-links" v-if="clusterViews.length"><span v-for="v in clusterViews" @click="view = v" :class="{selected: isActive(v)}">{{v.title}}</span></p>
<p class="nav-section" v-if="hostViews.length">Hosts</p> <p class="nav-section" v-if="hostViews.length">Hosts</p>
<p class="view-links" v-if="hostViews.length"><button type="button" v-for="v in hostViews" @click="view = v" :class="{selected: isActive(v)}">{{v.title}}</button></p> <p class="view-links" v-if="hostViews.length"><span v-for="v in hostViews" @click="view = v" :class="{selected: isActive(v)}">{{v.title}}</span></p>
</nav> </nav>
<main class="content"> <main class="content">
<h2 v-if="view">{{ view.type == 'cluster' ? 'Cluster ' : view.type == 'host' ? 'Host ' : '' }}{{view.title}}</h2> <h2 v-if="view">{{ view.type == 'cluster' ? 'Cluster ' : view.type == 'host' ? 'Host ' : '' }}{{view.title}}</h2>
@@ -152,6 +158,7 @@
</template> </template>
</div> </div>
</main> </main>
</div>
</template> </template>
</div> </div>
</body> </body>
+15 -33
View File
@@ -21,38 +21,31 @@ body {
/* Layout */ /* Layout */
html, body {
height: 100%;
margin: 0;
padding: 0;
}
#app {
display: grid;
height: 100vh;
grid-template: auto 1fr / 18em 1fr;
grid-template-areas: "h h" "s m";
}
header { header {
grid-area: h;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
padding: 0.5em 1em;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
padding: 0.5em 1em;
justify-content: space-between;
} }
#logo > img { #logo > img {
vertical-align: middle; vertical-align: middle;
margin-right: 0.5em;
} }
header .utils > * { header .utils > * {
margin-left: 1em; margin-left: 1em;
} }
.layout {
display: flex;
min-height: calc(100vh - 3em);
}
.sidebar { .sidebar {
grid-area: s; width: 18em;
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
padding: 0.5em; padding: 0.5em;
overflow-y: auto; overflow-y: auto;
flex-shrink: 0;
} }
.sidebar input[type="search"] { .sidebar input[type="search"] {
width: 100%; width: 100%;
@@ -66,7 +59,7 @@ header .utils > * {
} }
.content { .content {
grid-area: m; flex: 1;
padding: 1em; padding: 1em;
overflow-y: auto; overflow-y: auto;
} }
@@ -89,34 +82,25 @@ header .utils > * {
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
.sidebar:focus-within .view-links > button { .view-links > span {
color: var(--color);
}
.view-links > button {
display: block; display: block;
width: 100%;
padding: 0.2em 0.5em; padding: 0.2em 0.5em;
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
color: var(--dim); color: var(--dim);
border: none;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
background: none;
font: inherit;
text-align: left;
border-radius: 0;
} }
.view-links > button:last-child { .view-links > span:last-child {
border-bottom: none; border-bottom: none;
} }
.view-links > button:hover { .view-links > span:hover {
background: var(--hover); background: var(--hover);
color: var(--color); color: var(--color);
} }
.view-links > button.selected { .view-links > span.selected {
color: var(--accent) !important; color: var(--accent);
border-bottom-color: var(--accent); border-bottom-color: var(--accent);
} }
@@ -192,7 +176,6 @@ textarea, select, input {
padding: 0.3em 0.5em; padding: 0.3em 0.5em;
font-family: inherit; font-family: inherit;
font-size: inherit; font-size: inherit;
box-sizing: border-box;
} }
textarea:focus, select:focus, input:focus { textarea:focus, select:focus, input:focus {
outline: none; outline: none;
@@ -396,7 +379,6 @@ label.radio:focus-within {
.text-and-file { .text-and-file {
position: relative; position: relative;
display: block;
} }
.text-and-file textarea { .text-and-file textarea {
width: 100%; width: 100%;
+1 -1
View File
@@ -29,7 +29,7 @@
position:relative; position:relative;
textarea { textarea {
width: 100%; width: 64em;
} }
input[type="file"] { input[type="file"] {
+21 -14
View File
@@ -21,16 +21,21 @@
<div id="app"> <div id="app">
<header> <header>
<span id="logo"><img src="favicon.ico" /> Direktil Local Server</span> <div id="logo">
<span class="utils"> <img src="favicon.ico" />
<span>Direktil Local Server</span>
</div>
<div class="utils">
<span id="login-hdr" v-if="session.token"> <span id="login-hdr" v-if="session.token">
Logged in Logged in
<button class="link" @click="copyText(session.token)">&#x1F5D0;</button> <button class="link" @click="copyText(session.token)">&#x1F5D0;</button>
</span> </span>
<span>server <code>{{ serverVersion || '-----' }}</code></span> <span>server <code>{{ serverVersion || '-----' }}</code></span>
<span>ui <code>{{ uiHash || '-----' }}</code></span> <span>ui <code>{{ uiHash || '-----' }}</code></span>
<span :class="publicState ? 'green' : 'red'">&#x1F5F2;</span> <span :class="publicState ? 'green' : 'red'">&#x1F5F2;</span>
</span> </div>
</header> </header>
<div class="error" v-if="error"> <div class="error" v-if="error">
@@ -41,11 +46,11 @@
<div class="toasts"><div v-for="t in toasts" :class="'toast '+t.kind" @click="dismissToast(t.id)">{{ t.message }}</div></div> <div class="toasts"><div v-for="t in toasts" :class="'toast '+t.kind" @click="dismissToast(t.id)">{{ t.message }}</div></div>
<main class="content" v-if="!publicState"> <template v-if="!publicState">
<p>Not connected.</p> <p>Not connected.</p>
</main> </template>
<main class="content" v-else-if="publicState.Store.New"> <template v-else-if="publicState.Store.New">
<p>Store is new.</p> <p>Store is new.</p>
<p>Option 1: initialize a new store</p> <p>Option 1: initialize a new store</p>
<form @submit="unlockStore"> <form @submit="unlockStore">
@@ -59,17 +64,17 @@
<input type="file" ref="storeUpload" /> <input type="file" ref="storeUpload" />
<input type="submit" value="upload" /> <input type="submit" value="upload" />
</form> </form>
</main> </template>
<main class="content" v-else-if="!publicState.Store.Open"> <template v-else-if="!publicState.Store.Open">
<p>Store is not open.</p> <p>Store is not open.</p>
<form @submit="unlockStore"> <form @submit="unlockStore">
<input type="password" name="passphrase" v-model="forms.store.pass1" required placeholder="Passphrase" /> <input type="password" name="passphrase" v-model="forms.store.pass1" required placeholder="Passphrase" />
<input type="submit" value="unlock" :disabled="!forms.store.pass1" /> <input type="submit" value="unlock" :disabled="!forms.store.pass1" />
</form> </form>
</main> </template>
<main class="content" v-else-if="!state"> <template v-else-if="!state">
<p v-if="!session.token">Not logged in.</p> <p v-if="!session.token">Not logged in.</p>
<p v-else>Invalid token</p> <p v-else>Invalid token</p>
@@ -77,17 +82,18 @@
<input type="password" v-model="forms.store.pass1" required placeholder="Passphrase" /> <input type="password" v-model="forms.store.pass1" required placeholder="Passphrase" />
<input type="submit" value="log in"/> <input type="submit" value="log in"/>
</form> </form>
</main> </template>
<template v-else> <template v-else>
<div class="layout">
<nav class="sidebar"> <nav class="sidebar">
<input type="search" placeholder="Filter" v-model="viewFilter"/> <input type="search" placeholder="Filter" v-model="viewFilter"/>
<p class="nav-section">Admin</p> <p class="nav-section">Admin</p>
<p class="view-links"><button type="button" v-for="v in adminViews" @click="view = v" :class="{selected: isActive(v)}">{{v.title}}</button></p> <p class="view-links"><span v-for="v in adminViews" @click="view = v" :class="{selected: isActive(v)}">{{v.title}}</span></p>
<p class="nav-section" v-if="clusterViews.length">Clusters</p> <p class="nav-section" v-if="clusterViews.length">Clusters</p>
<p class="view-links" v-if="clusterViews.length"><button type="button" v-for="v in clusterViews" @click="view = v" :class="{selected: isActive(v)}">{{v.title}}</button></p> <p class="view-links" v-if="clusterViews.length"><span v-for="v in clusterViews" @click="view = v" :class="{selected: isActive(v)}">{{v.title}}</span></p>
<p class="nav-section" v-if="hostViews.length">Hosts</p> <p class="nav-section" v-if="hostViews.length">Hosts</p>
<p class="view-links" v-if="hostViews.length"><button type="button" v-for="v in hostViews" @click="view = v" :class="{selected: isActive(v)}">{{v.title}}</button></p> <p class="view-links" v-if="hostViews.length"><span v-for="v in hostViews" @click="view = v" :class="{selected: isActive(v)}">{{v.title}}</span></p>
</nav> </nav>
<main class="content"> <main class="content">
<h2 v-if="view">{{ view.type == 'cluster' ? 'Cluster ' : view.type == 'host' ? 'Host ' : '' }}{{view.title}}</h2> <h2 v-if="view">{{ view.type == 'cluster' ? 'Cluster ' : view.type == 'host' ? 'Host ' : '' }}{{view.title}}</h2>
@@ -152,6 +158,7 @@
</template> </template>
</div> </div>
</main> </main>
</div>
</template> </template>
</div> </div>
</body> </body>
+15 -33
View File
@@ -21,38 +21,31 @@ body {
/* Layout */ /* Layout */
html, body {
height: 100%;
margin: 0;
padding: 0;
}
#app {
display: grid;
height: 100vh;
grid-template: auto 1fr / 18em 1fr;
grid-template-areas: "h h" "s m";
}
header { header {
grid-area: h;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
padding: 0.5em 1em;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
padding: 0.5em 1em;
justify-content: space-between;
} }
#logo > img { #logo > img {
vertical-align: middle; vertical-align: middle;
margin-right: 0.5em;
} }
header .utils > * { header .utils > * {
margin-left: 1em; margin-left: 1em;
} }
.layout {
display: flex;
min-height: calc(100vh - 3em);
}
.sidebar { .sidebar {
grid-area: s; width: 18em;
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
padding: 0.5em; padding: 0.5em;
overflow-y: auto; overflow-y: auto;
flex-shrink: 0;
} }
.sidebar input[type="search"] { .sidebar input[type="search"] {
width: 100%; width: 100%;
@@ -66,7 +59,7 @@ header .utils > * {
} }
.content { .content {
grid-area: m; flex: 1;
padding: 1em; padding: 1em;
overflow-y: auto; overflow-y: auto;
} }
@@ -89,34 +82,25 @@ header .utils > * {
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
.sidebar:focus-within .view-links > button { .view-links > span {
color: var(--color);
}
.view-links > button {
display: block; display: block;
width: 100%;
padding: 0.2em 0.5em; padding: 0.2em 0.5em;
cursor: pointer; cursor: pointer;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
color: var(--dim); color: var(--dim);
border: none;
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
background: none;
font: inherit;
text-align: left;
border-radius: 0;
} }
.view-links > button:last-child { .view-links > span:last-child {
border-bottom: none; border-bottom: none;
} }
.view-links > button:hover { .view-links > span:hover {
background: var(--hover); background: var(--hover);
color: var(--color); color: var(--color);
} }
.view-links > button.selected { .view-links > span.selected {
color: var(--accent) !important; color: var(--accent);
border-bottom-color: var(--accent); border-bottom-color: var(--accent);
} }
@@ -192,7 +176,6 @@ textarea, select, input {
padding: 0.3em 0.5em; padding: 0.3em 0.5em;
font-family: inherit; font-family: inherit;
font-size: inherit; font-size: inherit;
box-sizing: border-box;
} }
textarea:focus, select:focus, input:focus { textarea:focus, select:focus, input:focus {
outline: none; outline: none;
@@ -396,7 +379,6 @@ label.radio:focus-within {
.text-and-file { .text-and-file {
position: relative; position: relative;
display: block;
} }
.text-and-file textarea { .text-and-file textarea {
width: 100%; width: 100%;