Completely redesign the UI

This commit is contained in:
Melon 2024-06-23 12:33:57 +01:00
parent 85f3bd66ae
commit f4b1c896ba
Signed by: melon
GPG Key ID: 6C9D970C50D26A25
58 changed files with 2136 additions and 1178 deletions

View File

@ -1,6 +1,10 @@
VITE_SSO_ORIGIN=http://localhost:9090 VITE_SSO_ORIGIN=http://localhost:9090
VITE_OAUTH2_CLIENT_ID=b5a9a8df-827c-4925-b1c1-1940abcf356b VITE_OAUTH2_CLIENT_ID=b5a9a8df-827c-4925-b1c1-1940abcf356b
VITE_OAUTH2_CLIENT_SCOPE=openid profile name
VITE_LOGOUT_PAGE=http://localhost:9095/logout
VITE_API_VIOLET=http://localhost:9095/v1/violet VITE_API_VIOLET=http://localhost:9095/v1/violet
VITE_API_ORCHID=http://localhost:9095/v1/orchid VITE_API_ORCHID=http://localhost:9095/v1/orchid
VITE_API_AZALEA=http://localhost:9095/v1/azalea
VITE_API_SITE_HOSTING=http://localhost:9095/v1/sites VITE_API_SITE_HOSTING=http://localhost:9095/v1/sites

View File

@ -1,6 +1,10 @@
VITE_SSO_ORIGIN=https://sso.1f349.com VITE_SSO_ORIGIN=https://sso.1f349.com
VITE_OAUTH2_CLIENT_ID=9b11a141-bcb8-4140-9c88-531a5d7bf15d VITE_OAUTH2_CLIENT_ID=9b11a141-bcb8-4140-9c88-531a5d7bf15d
VITE_OAUTH2_CLIENT_SCOPE=openid profile name
VITE_LOGOUT_PAGE=https://1f349.com
VITE_API_VIOLET=https://api.1f349.com/v1/violet VITE_API_VIOLET=https://api.1f349.com/v1/violet
VITE_API_ORCHID=https://api.1f349.com/v1/orchid VITE_API_ORCHID=https://api.1f349.com/v1/orchid
VITE_API_AZALEA=https://api.1f349.com/v1/azalea
VITE_API_SITE_HOSTING=https://sites.1f349.com/api.php VITE_API_SITE_HOSTING=https://sites.1f349.com/api.php

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 MiB

View File

@ -1,208 +1,301 @@
<script lang="ts"> <script lang="ts">
import {onMount, type SvelteComponent} from "svelte"; import {onMount, type SvelteComponent} from "svelte";
import GeneralView from "./views/GeneralView.svelte"; import GeneralView from "./views/HomeView.svelte";
import RoutesView from "./views/RoutesView.svelte"; import RoutesView from "./views/RoutesView.svelte";
import RedirectsView from "./views/RedirectsView.svelte"; import RedirectsView from "./views/RedirectsView.svelte";
import CertificatesView from "./views/CertificatesView.svelte"; import CertificatesView from "./views/CertificatesView.svelte";
import SitesView from "./views/SitesView.svelte"; import SitesView from "./views/SitesView.svelte";
import {loginStore, parseJwt, type LoginStore} from "./stores/login"; import {loginStore} from "./stores/login";
import {domainOption} from "./stores/domain-option"; import {domainOption, domainOptions, setDomainOption} from "./stores/domain-option";
import {LOGIN} from "./utils/login"; import {LOGIN} from "./utils/login";
import DomainIcon from "./icons/Domain.svelte";
import HomeIcon from "./icons/Home.svelte";
import CertificateIcon from "./icons/Certificate.svelte";
import RouteIcon from "./icons/Route.svelte";
import SiteIcon from "./icons/Site.svelte";
import RedirectIcon from "./icons/Redirect.svelte";
import MenuIcon from "./icons/Menu.svelte";
import StatusIcon from "./icons/Status.svelte";
import SourceIcon from "./icons/Source.svelte";
import DomainsView from "./views/DomainsView.svelte";
import ChevronDownIcon from "./icons/ChevronDown.svelte";
import ChevronUpIcon from "./icons/ChevronUp.svelte";
import Popover from "./components/Popover.svelte";
import UserPopover from "./components/popover/UserPopover.svelte";
let sidebarOptions: Array<{name: string; view: typeof SvelteComponent<{}>}> = [ type SidebarTab = {name: string; icon: typeof SvelteComponent<{}>; view: typeof SvelteComponent<{}>};
{name: "General", view: GeneralView},
{name: "Routes", view: RoutesView}, let sidebarOptions: Array<SidebarTab> = [
{name: "Redirects", view: RedirectsView}, {name: "Home", icon: HomeIcon, view: GeneralView},
{name: "Certificates", view: CertificatesView}, {name: "Routes", icon: RouteIcon, view: RoutesView},
{name: "Sites", view: SitesView}, {name: "Redirects", icon: RedirectIcon, view: RedirectsView},
{name: "Certificates", icon: CertificateIcon, view: CertificatesView},
{name: "Domains", icon: DomainIcon, view: DomainsView},
{name: "Sites", icon: SiteIcon, view: SitesView},
]; ];
let sidebarSelection: {name: string; view: typeof SvelteComponent<{}>} = sidebarOptions[0]; if (!import.meta.env.VITE_API_AZALEA) sidebarOptions = sidebarOptions.filter(x => x.name !== "Domains");
let sidebarSelection: SidebarTab = findSidebarTab(localStorage.getItem("sidebar-tab") || "");
function findSidebarTab(name: string): SidebarTab {
return sidebarOptions.find(x => x.name === name) || sidebarOptions[0];
}
function setSidebarTab(name: string) {
sidebarSelection = findSidebarTab(name);
localStorage.setItem("sidebar-tab", name);
}
let tokenPerms: string[] = []; let tokenPerms: string[] = [];
$: tokenPerms = []; $: tokenPerms = [];
let domainOptions: string[]; let sidebarOpen: boolean = localStorage.getItem("sidebar-open") == "yes";
$: domainOptions = getDomainOptions($loginStore);
function getDomainOptions(login: LoginStore | null) { function toggleSidebar() {
let accessToken = login?.tokens?.access; sidebarOpen = !sidebarOpen;
if (accessToken == null) return []; localStorage.setItem("sidebar-open", sidebarOpen ? "yes" : "no");
let jwt = parseJwt(accessToken);
if (!jwt) return [];
return jwt.per.filter((x: string) => x.startsWith("domain:owns=")).map((x: string) => x.slice("domain:owns=".length));
} }
let userDropdownOpen: boolean = false;
function toggleUserDropdown() {
userDropdownOpen = !userDropdownOpen;
}
let hasPopover: boolean;
$: hasPopover = userDropdownOpen;
onMount(() => { onMount(() => {
LOGIN.init(); LOGIN.userinfo();
LOGIN.userinfo(false);
}); });
</script> </script>
<header> <nav id="sidebar" class:sidebar-open={sidebarOpen}>
<div> <button class="title" on:click={() => setSidebarTab("")}>
<h1>🍉 Admin Panel</h1> <div class="icon">🍉</div>
</div> <div class="text">1f349</div>
<div class="flex-gap" /> </button>
{#each sidebarOptions as item (item.name)} {#each sidebarOptions as item (item.name)}
<button class="header-tab" on:click={() => (sidebarSelection = item)} class:selected={item == sidebarSelection}>{item.name}</button> <button on:click={() => setSidebarTab(item.name)} class:selected={item == sidebarSelection}>
{#if item.icon != null}
<div class="icon"><svelte:component this={item.icon} /></div>
{/if}
<div class="text">{item.name}</div>
</button>
{/each} {/each}
<div class="flex-gap" /> <div class="flex-gap" />
<div class="nav-link"> <a href="https://status.1f349.com" target="_blank" class="status">
<a href="https://status.1f349.com" target="_blank">Status</a> <div class="icon"><StatusIcon /></div>
</div> <div class="text">Status</div>
{#if $loginStore == null} </a>
<div class="login-view"> <a href="https://github.com/1f349/admin.1f349.com" target="_blank">
<button on:click={() => LOGIN.userinfo(true)}>Login</button> <div class="icon"><SourceIcon /></div>
</div> <div class="text">{import.meta.env.VITE_APP_VERSION}</div>
{:else} </a>
<div class="user-view"> </nav>
<img class="user-avatar" src={$loginStore.userinfo.picture} alt="{$loginStore.userinfo.name}'s profile picture" />
<div class="user-display-name">{$loginStore.userinfo.name}</div> <div id="root" class:hasPopover>
<button <div id="sidebar-gap" class:sidebar-open={sidebarOpen} />
on:click={() => { <div id="content">
$loginStore = null; <header>
localStorage.removeItem("login-session"); <button id="menu-toggle" on:click={() => toggleSidebar()}>
LOGIN.logout(); <MenuIcon />
}}
>
Logout
</button> </button>
<div>
<label>
<span>Domain:</span>
<select value={$domainOption} on:change={x => setDomainOption(x.currentTarget.value)}>
{#each $domainOptions as domain}
<option value={domain}>{domain}</option>
{/each}
</select>
</label>
</div>
<div class="flex-gap" />
<div class="nav-link"></div>
{#if $loginStore != null}
<div style="position:relative;">
<button id="user-dropdown" on:click={() => toggleUserDropdown()}>
<img class="user-avatar" src={$loginStore.userinfo.picture} alt="{$loginStore.userinfo.name}'s profile picture" />
<div class="user-display-name">{$loginStore.userinfo.name}</div>
<div>
{#if userDropdownOpen}
<ChevronUpIcon />
{:else}
<ChevronDownIcon />
{/if}
</div>
</button>
{#if userDropdownOpen}
<Popover on:click={() => (userDropdownOpen = false)}>
<UserPopover />
</Popover>
{/if}
</div>
{/if}
</header>
<div id="content-inner">
<main>
{#if $loginStore == null}
<div id="login-view">Please login to continue</div>
{:else}
<svelte:component this={sidebarSelection.view} />
{/if}
</main>
</div> </div>
{/if}
</header>
<main>
{#if $loginStore == null}
<div id="login-view">Please login to continue</div>
{:else}
<div id="option-view">
<svelte:component this={sidebarSelection.view} />
</div>
{/if}
</main>
<footer>
<div class="meta-version">
Version: <code>{import.meta.env.VITE_APP_VERSION}</code>
, {import.meta.env.VITE_APP_LASTMOD}
</div> </div>
<div> </div>
<a href="https://github.com/1f349/admin.1f349.com" target="_blank">Source</a>
</div>
<div>
<label>
<span>Domain:</span>
<select bind:value={$domainOption}>
<option value="*">All</option>
{#each domainOptions as domain}
<option value={domain}>{domain}</option>
{/each}
</select>
</label>
</div>
</footer>
<style lang="scss"> <style lang="scss">
@import "values.scss";
#root {
display: flex;
flex-direction: row;
flex-grow: 1;
}
#sidebar-gap {
width: 50px;
transition: linear 100ms width;
flex-shrink: 0;
&.sidebar-open {
width: $sidebar-width;
}
}
#sidebar {
position: fixed;
top: 0;
left: 0;
width: $sidebar-width-short;
transition: linear 100ms width;
display: flex;
flex-direction: column;
flex-wrap: nowrap;
overflow-x: hidden;
z-index: 999;
background-color: $theme-sidebar;
height: 100vh;
button,
a {
@include button-reset;
font-family: "Iosevka";
font-weight: 400;
font-size: 20px;
font-weight: 400;
padding: 8px 13px;
text-wrap: nowrap;
text-align: left;
overflow: hidden;
line-height: 0;
display: flex;
flex-direction: row;
gap: 12px;
.text {
line-height: 24px;
vertical-align: middle;
}
&:hover:not(.title) {
background-color: $sidebar-highlight;
}
}
button.selected {
background-color: $sidebar-highlight;
}
button.title {
height: $header-height;
align-items: center;
padding: 8px 10px;
background-color: $theme-header;
.icon {
font-size: 140%;
}
}
&.sidebar-open,
&:hover {
width: $sidebar-width;
}
}
#menu-toggle {
@include button-green-highlight;
width: 50px;
}
#content {
display: flex;
flex-direction: column;
flex-grow: 1;
main {
flex-grow: 1;
}
}
header { header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
height: 70px; height: $header-height;
padding: 0 32px; background-color: $theme-header;
background-color: #2c2c2c;
box-shadow:
0 4px 8px #0003,
0 6px 20px #00000030;
gap: 16px;
z-index: 1; z-index: 1;
position: relative; }
h1 { #user-dropdown {
font-size: 32px; @include button-green-highlight($square: false);
margin: 0;
}
.nav-link { display: flex;
font-size: 24px; flex-direction: row;
} align-items: center;
gap: 9px;
padding: 9px 12px;
aspect-ratio: none;
width: auto;
height: 50px;
.flex-gap { .user-avatar {
flex-grow: 1; width: 32px;
} height: 32px;
border-radius: 50%;
.user-view {
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
.user-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
}
}
button,
a {
background-color: transparent;
border: none;
box-shadow: none;
box-sizing: border-box;
color: tomato;
cursor: pointer;
font-size: 20px;
font-weight: 700;
line-height: 24px;
padding: 8px;
border-radius: 0.375rem;
&.header-tab {
box-shadow: none;
box-sizing: border-box;
color: tomato;
cursor: pointer;
font-size: 20px;
font-weight: 700;
line-height: 24px;
&:hover {
background-color: #1c1c1c;
}
&.selected {
background-color: #1c1c1c;
}
}
} }
} }
main { #content #content-inner {
display: flex; display: flex;
flex-direction: column;
flex-grow: 1; flex-grow: 1;
align-items: stretch; align-items: stretch;
height: 0; height: 0;
margin-inline: 32px;
#login-view { main {
padding: 16px;
}
#option-view {
box-sizing: border-box;
overflow-y: auto;
height: 100%;
flex-grow: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-grow: 1;
align-items: stretch;
font-size: 14px;
#login-view {
padding: 16px;
}
align-self: center;
width: 100%;
max-width: 1280px;
padding: 24px 0px 32px;
transition: opacity 300ms cubic-bezier(0.4, 0, 0.2, 1);
} }
} }
footer {
padding: 8px;
background-color: #2c2c2c55;
box-shadow:
0 -4px 8px #0003,
0 -6px 20px #00000030;
display: flex;
flex-direction: row;
justify-content: space-between;
}
</style> </style>

View File

@ -1,10 +1,4 @@
$theme-text: rgba(255, 255, 255, 0.87); @import "values.scss";
$theme-bg: #242424;
@media (prefers-color-scheme: light) {
$theme-text: #213547;
$theme-bg: #ffffff;
}
:root { :root {
font-family: Ubuntu, Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; font-family: Ubuntu, Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
@ -23,17 +17,21 @@ $theme-bg: #242424;
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
} }
body {
background-image: url('/jason-leem-50bzI1F6urA-unsplash.jpg');
background-position: center;
}
@font-face { @font-face {
font-family: 'Ubuntu'; font-family: 'Ubuntu';
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
font-display: swap; font-display: swap;
src: url('/fonts/Ubuntu.woff2') format('woff2'); src: url('/fonts/Ubuntu-Regular.ttf') format('truetype');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Ubuntu';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/Ubuntu-Bold.ttf') format('truetype');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
} }
@ -44,6 +42,14 @@ body {
src: url('/fonts/FiraCode-Regular.woff2') format('woff2'); src: url('/fonts/FiraCode-Regular.woff2') format('woff2');
} }
@font-face {
font-family: "Iosevka";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/IosevkaCustomNerdFont-Regular.ttf') format('truetype');
}
#app { #app {
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
@ -66,107 +72,50 @@ body {
margin: 0; margin: 0;
} }
.flex-gap {
flex-grow: 1;
}
code, code,
.code-font { .code-font {
font-family: 'Fira Code', monospace; font-family: 'Fira Code', monospace;
} }
.btn-green, table {
.btn-red, border-spacing: 0px;
.btn-blue, border-collapse: initial;
.btn-yellow {
display: table-cell;
border: none;
box-shadow: none;
box-sizing: border-box;
color: black;
cursor: pointer;
font-size: 20px;
font-weight: 700;
line-height: 24px;
height: 50px;
padding: 4px 16px;
vertical-align: middle;
box-shadow: 0 4px 8px #0003, 0 6px 20px #00000030;
&:hover { thead,
color: black; tbody tr {
} background-color: $table-row;
}
.btn-green { &:hover {
background-color: #209c6f; background-color: $table-row-highlight;
}
table.main-table {
border-collapse: collapse;
border-spacing: 0;
width: 100%;
thead {
background-color: #33333355;
position: sticky;
top: 0;
z-index: 9999;
box-shadow: 0 4px 8px #0003, 0 6px 20px #00000030;
}
th,
td {
padding: 6px 8px 6px 8px;
text-align: center;
}
tr:nth-child(2n) {
background-color: #2a2a2a55;
}
tr:nth-child(2n + 1) {
background-color: #24242455;
}
.invert-rows {
tr:nth-child(2n) {
background-color: #24242455;
} }
tr:nth-child(2n + 1) { th {
background-color: #2a2a2a55; background-color: $table-header;
padding: 10px 15px;
text-align: left;
white-space: nowrap;
height: 40px;
}
td {
display: table-cell;
vertical-align: inherit;
text-align: left;
font-size: 0.875rem;
line-height: 1rem;
padding: 0px 15px;
white-space: nowrap;
height: 40px;
} }
} }
}
.wrapper { &.action-table tbody tr:not(.empty-row) td:last-child {
display: flex; display: flex;
flex-direction: column; justify-content: flex-end;
overflow: hidden; padding-right: 0;
margin: 2em;
backdrop-filter: blur(32px) saturate(180%);
-webkit-backdrop-filter: blur(32px) saturate(180%);
background-color: rgba(17, 25, 40, 0.75);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.125);
.scrolling-area {
overflow: auto;
height: 100%;
}
}
.text-padding {
padding: 4px 16px;
}
.footer {
height: 50px;
background-color: #2c2c2c55;
box-shadow: 0 -4px 8px #0003, 0 -6px 20px #00000030;
display: flex;
flex-direction: row;
.meta-info {
line-height: 50px;
padding-inline: 16px;
} }
} }

View File

@ -0,0 +1,45 @@
<script lang="ts">
import EditIcon from "../icons/Edit.svelte";
import RemoveIcon from "../icons/Remove.svelte";
type T = $$Generic<any>;
export let data: T;
export let edit: ((t: T) => void) | null;
export let remove: ((t: T) => void) | null;
</script>
<div class="action-menu">
{#if edit != null}
<button class="edit" on:click={() => edit(data)}>
<EditIcon />
</button>
{/if}
{#if remove != null}
<button class="remove" on:click={() => remove(data)}>
<RemoveIcon />
</button>
{/if}
</div>
<style lang="scss">
@import "../values.scss";
.action-menu {
height: 100%;
display: flex;
button {
display: block;
height: 100%;
&.edit {
@include button-green-highlight;
}
&.remove {
@include button-red-highlight;
}
}
}
</style>

View File

@ -0,0 +1,70 @@
<script lang="ts">
import Popup from "./Popup.svelte";
import XIcon from "../icons/X.svelte";
import {createEventDispatcher} from "svelte";
const dispatch = createEventDispatcher();
export let name: string;
export let show: boolean = false;
function save() {
dispatch("save");
}
function close() {
show = false;
}
</script>
<Popup bind:show on:click={() => close()}>
<div class="popup-inner">
<div class="title-row">
<h2>{name}</h2>
<button class="close-button" on:click={() => close()}>
<XIcon />
</button>
</div>
<slot />
<div class="button-row">
<button class="btn-close" on:click={() => close()}>Close</button>
<button class="btn-save" on:click={() => save()}>Save</button>
</div>
</div>
</Popup>
<style lang="scss">
@import "../values.scss";
.popup-inner {
padding: 32px;
display: flex;
flex-direction: column;
gap: 16px;
}
.title-row {
display: flex;
flex-direction: row;
justify-content: space-between;
.close-button {
@include button-red-highlight;
}
}
.button-row {
display: flex;
flex-direction: row;
justify-content: flex-end;
gap: 16px;
.btn-close {
@include button-green-invert-box;
}
.btn-save {
@include button-green-box;
}
}
</style>

View File

@ -61,6 +61,7 @@
<style lang="scss"> <style lang="scss">
.dropdown-check-list { .dropdown-check-list {
position: relative; position: relative;
display: inline-block;
> button { > button {
margin: 0; margin: 0;
@ -121,6 +122,10 @@
> div { > div {
margin-right: 6px; margin-right: 6px;
> label {
display: block;
}
} }
} }

View File

@ -0,0 +1,35 @@
<script lang="ts">
export let layer: number = 1000;
</script>
<button id="popover" style="--popover-layer: {layer};" on:click />
<div id="popover-content" style="--popover-layer: {layer};">
<slot />
</div>
<style lang="scss">
@import "../values.scss";
#popover {
@include button-reset;
position: fixed;
top: 0;
left: 0;
z-index: var(--popover-layer);
width: 100%;
height: 100%;
cursor: default;
}
#popover-content {
z-index: calc(var(--popover-layer) + 1);
position: absolute;
top: 100%;
right: 0;
min-width: 100%;
background-color: $theme-header;
cursor: auto;
}
</style>

View File

@ -0,0 +1,72 @@
<script lang="ts">
export let layer: number = 2000;
export let show: boolean = false;
</script>
<button id="popup-backdrop" class:show style="--popup-layer: {layer};" on:click />
<div id="popup" class:show style="--popup-layer:{layer};">
<slot />
</div>
<style lang="scss">
@import "../values.scss";
#popup-backdrop {
@include button-reset;
position: fixed;
top: 0;
left: 0;
z-index: var(--popup-layer);
width: 100%;
height: 100%;
cursor: default;
background-color: rgba(0,0,0,0.55);
display: none;
&.show {
display: block;
animation: fade 0.2s ease-out;
}
}
@keyframes fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
#popup {
z-index: calc(var(--popup-layer) + 1);
position: fixed;
top: 50vh;
left: 50vw;
transform: translate(-50%, -50%);
background-color: $theme-header;
cursor: auto;
min-width: 400px;
display: none;
@media (max-width: 400px) {
min-width: 100vw;
}
&.show {
display: block;
animation: zoom 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
}
@keyframes zoom {
from {
transform: translate(-50%, -50%) scale(0.95);
}
to {
transform: translate(-50%, -50%) scale(1);
}
}
</style>

View File

@ -0,0 +1,19 @@
<script lang="ts">
import type {IPromiseLike} from "../utils/promise-like";
type T = $$Generic<IPromiseLike>;
export let value: T;
function hasError(value: T) {
return value.error() != null;
}
</script>
{#if $value.isLoading()}
<slot name="loading" />
{:else if hasError($value)}
<slot name="error" reason={$value.error()} />
{:else}
<slot name="ok" value={$value} />
{/if}

View File

@ -0,0 +1,35 @@
<script lang="ts">
import PromiseLike from "./PromiseLike.svelte";
import type {IPromiseLike} from "../utils/promise-like";
type T = $$Generic<IPromiseLike>;
export let value: T;
</script>
<PromiseLike {value}>
<div class="text-padding" slot="loading">
<div>Loading...</div>
</div>
<div class="text-padding" slot="error" let:reason>
<div>Administrator... I hardly know her?</div>
<div>{reason}</div>
</div>
<table class="action-table" slot="ok" let:value>
<thead>
<slot name="headers" {value} />
</thead>
<tbody>
<slot name="rows" {value} />
</tbody>
</table>
</PromiseLike>
<style lang="scss">
.text-padding,
.text-padding div {
padding: 8px;
}
</style>

View File

@ -1,49 +0,0 @@
<script lang="ts">
import {createEventDispatcher} from "svelte";
import {redirectKeys, type Redirect} from "../types/target";
import Flags from "./Flags.svelte";
import RedirectCode from "./RedirectCode.svelte";
const dispatch = createEventDispatcher();
let redirect: Redirect = {src: "", dst: "", desc: "", flags: 0, code: 302, active: true};
const descCols = 50;
</script>
<tr class="created">
<td><input type="text" class="code-font" bind:value={redirect.src} size={Math.max(20, redirect.src.length + 2)} /></td>
<td><input type="text" class="code-font" bind:value={redirect.dst} size={Math.max(20, redirect.dst.length + 2)} /></td>
<td><Flags bind:value={redirect.flags} editable keys={redirectKeys} /></td>
<td><RedirectCode bind:code={redirect.code} editable /></td>
<td class="desc"><textarea rows="3" cols={descCols} bind:value={redirect.desc} /></td>
<td><input type="checkbox" bind:checked={redirect.active} /></td>
<td>
<button
on:click={() => {
dispatch("make", redirect);
redirect = {src: "", dst: "", desc: "", flags: 0, code: 302, active: true};
}}
>
Create
</button>
</td>
</tr>
<style lang="scss">
tr:nth-child(2n) {
background-color: #2a2a2a55;
}
tr:nth-child(2n + 1) {
background-color: #24242455;
}
tr.created {
position: sticky;
top: 0;
}
.desc textarea {
resize: none;
}
</style>

View File

@ -1,81 +1,60 @@
<script lang="ts"> <script lang="ts">
import type {Writable} from "svelte/store"; import {redirectKeys, type Redirect} from "../types/target";
import {type CSPair, noCPair, noSPair, yesCPair} from "../types/cspair";
import {redirectKeys, redirectEqual, type Redirect} from "../types/target";
import Flags from "./Flags.svelte"; import Flags from "./Flags.svelte";
import RedirectCode from "./RedirectCode.svelte"; import RedirectCode from "./RedirectCode.svelte";
import type {RestItem} from "../utils/rest-table";
import ActionMenu from "./ActionMenu.svelte";
import ActionPopup from "./ActionPopup.svelte";
export let value: Writable<CSPair<Redirect>>; export let value: RestItem<Redirect>;
let editItem: Redirect = {
src: "",
dst: "",
flags: 0,
code: 0,
active: false,
};
let item: CSPair<Redirect>; let editPopup: boolean = false;
$: item = $value;
function resetRedirect(): any { function save() {
item.client = JSON.parse(JSON.stringify(item.server)); value.update(editItem);
} }
const descCols = 50;
</script> </script>
{#if noCPair(item)} <tr>
<tr class="deleted"> <td class="code-font"><a href="https://{value.data.src}" target="_blank">{value.data.src}</a></td>
<td class="code-font"><a href="https://{item.server.src}" target="_blank">{item.server.src}</a></td> <td>{value.data.dst}</td>
<td><input type="text" class="code-font" disabled bind:value={item.server.dst} size={Math.max(20, item.server.dst.length + 2)} /></td> <td><Flags bind:value={value.data.flags} keys={redirectKeys} /></td>
<td><Flags value={item.server.flags} keys={redirectKeys} /></td> <td><RedirectCode bind:code={value.data.code} /></td>
<td><RedirectCode bind:code={item.server.code} /></td> <td><input type="checkbox" disabled checked={value.data.active} /></td>
<td class="desc"><textarea rows="3" cols={descCols} disabled value={item.server.desc} /></td> <td>
<td><input type="checkbox" disabled checked={false} /></td> <ActionMenu
<td><button on:click={() => resetRedirect()}>Restore</button></td> data={value}
</tr> edit={() => {
{:else if yesCPair(item)} editItem = JSON.parse(JSON.stringify(value.data));
<tr class:created={noSPair(item)} class:modified={!noSPair(item) && !redirectEqual(item.client, item.server)}> editPopup = true;
<td class="code-font"><a href="https://{item.client.src}" target="_blank">{item.client.src}</a></td> }}
<td><input type="text" class="code-font" bind:value={item.client.dst} size={Math.max(20, item.client.dst.length + 2)} /></td> remove={() => value.remove()}
<td><Flags bind:value={item.client.flags} editable keys={redirectKeys} /></td> />
<td><RedirectCode bind:code={item.client.code} editable /></td>
<td class="desc"><textarea rows="3" cols={descCols} bind:value={item.client.desc} /></td> <ActionPopup name="Edit Redirect" bind:show={editPopup} on:save={save}>
<td><input type="checkbox" bind:checked={item.client.active} /></td> <div>Source</div>
<td> <div class="code-font">{editItem.src}</div>
{#if !noSPair(item)} <div>Destination</div>
<button on:click={() => resetRedirect()}>Reset</button> <div><input type="text" class="code-font" bind:value={editItem.dst} size={Math.max(20, editItem.dst.length + 2)} /></div>
{/if} <div>Flags</div>
<button on:click={() => (item.client = null)}>Delete</button> <div><Flags bind:value={editItem.flags} editable keys={redirectKeys} /></div>
</td> <div>Redirect Code</div>
</tr> <div><RedirectCode bind:code={editItem.code} editable /></div>
{:else} <div>Active</div>
<div>Invalid redirect row: please report this error</div> <div><input type="checkbox" bind:checked={editItem.active} /></div>
{/if} </ActionPopup>
</td>
</tr>
<style lang="scss"> <style lang="scss">
tr.created {
background-color: #1a510077;
&:nth-child(2n) {
background-color: #10330077;
}
}
tr.modified {
background-color: #51510077;
&:nth-child(2n) {
background-color: #33330077;
}
}
tr.deleted {
background-color: #51000077;
&:nth-child(2n) {
background-color: #33000077;
}
}
td input[type="text"] { td input[type="text"] {
padding: 4px; padding: 4px;
} }
.desc textarea {
resize: none;
}
</style> </style>

View File

@ -1,47 +0,0 @@
<script lang="ts">
import {createEventDispatcher} from "svelte";
import {routeKeys, type Route} from "../types/target";
import Flags from "./Flags.svelte";
const dispatch = createEventDispatcher();
let route: Route = {src: "", dst: "", desc: "", flags: 0, active: true};
const descCols = 50;
</script>
<tr class="created">
<td><input type="text" class="code-font" bind:value={route.src} size={Math.max(20, route.src.length + 2)} /></td>
<td><input type="text" class="code-font" bind:value={route.dst} size={Math.max(20, route.dst.length + 2)} /></td>
<td><Flags bind:value={route.flags} editable keys={routeKeys} /></td>
<td class="desc"><textarea rows="3" cols={descCols} bind:value={route.desc} /></td>
<td><input type="checkbox" bind:checked={route.active} /></td>
<td>
<button
on:click={() => {
dispatch("make", route);
route = {src: "", dst: "", desc: "", flags: 0, active: true};
}}
>
Create
</button>
</td>
</tr>
<style lang="scss">
tr:nth-child(2n) {
background-color: #2a2a2a55;
}
tr:nth-child(2n + 1) {
background-color: #24242455;
}
tr.created {
position: sticky;
top: 0;
}
.desc textarea {
resize: none;
}
</style>

View File

@ -1,78 +1,55 @@
<script lang="ts"> <script lang="ts">
import type {Writable} from "svelte/store"; import {type Route, routeKeys} from "../types/target";
import {type CSPair, noCPair, noSPair, yesCPair} from "../types/cspair";
import {type Route, routeKeys, routeEqual} from "../types/target";
import Flags from "./Flags.svelte"; import Flags from "./Flags.svelte";
import type {RestItem} from "../utils/rest-table";
import ActionMenu from "./ActionMenu.svelte";
import ActionPopup from "./ActionPopup.svelte";
export let value: Writable<CSPair<Route>>; export let value: RestItem<Route>;
let editItem: Route = {
src: "",
dst: "",
flags: 0,
active: false,
};
let item: CSPair<Route>; let editPopup: boolean = false;
$: item = $value;
function resetRoute(): any { function save() {
item.client = JSON.parse(JSON.stringify(item.server)); value.update(editItem);
} }
const descCols = 50;
</script> </script>
{#if noCPair(item)} <tr>
<tr class="deleted"> <td class="code-font"><a href="https://{value.data.src}" target="_blank">{value.data.src}</a></td>
<td class="code-font"><a href="https://{item.server.src}" target="_blank">{item.server.src}</a></td> <td>{value.data.dst}</td>
<td><input type="text" class="code-font" disabled bind:value={item.server.dst} size={Math.max(20, item.server.dst.length + 2)} /></td> <td><Flags bind:value={value.data.flags} keys={routeKeys} /></td>
<td><Flags value={item.server.flags} keys={routeKeys} /></td> <td><input type="checkbox" disabled checked={value.data.active} /></td>
<td class="desc"><textarea rows="3" cols={descCols} disabled value={item.server.desc} /></td> <td>
<td><input type="checkbox" disabled checked={false} /></td> <ActionMenu
<td><button on:click={() => resetRoute()}>Restore</button></td> data={value}
</tr> edit={() => {
{:else if yesCPair(item)} editItem = JSON.parse(JSON.stringify(value.data));
<tr class:created={noSPair(item)} class:modified={!noSPair(item) && !routeEqual(item.client, item.server)}> editPopup = true;
<td class="code-font"><a href="https://{item.client.src}" target="_blank">{item.client.src}</a></td> }}
<td><input type="text" class="code-font" bind:value={item.client.dst} size={Math.max(20, item.client.dst.length + 2)} /></td> remove={() => value.remove()}
<td><Flags bind:value={item.client.flags} editable keys={routeKeys} /></td> />
<td class="desc"><textarea rows="3" cols={descCols} bind:value={item.client.desc} /></td>
<td><input type="checkbox" bind:checked={item.client.active} /></td> <ActionPopup name="Edit Route" bind:show={editPopup} on:save={save}>
<td> <div>Source</div>
{#if !noSPair(item)} <div><input type="text" class="code-font" bind:value={editItem.src} size={Math.max(20, value.data.dst.length + 2)} /></div>
<button on:click={() => resetRoute()}>Reset</button> <div>Destination</div>
{/if} <div><input type="text" class="code-font" bind:value={editItem.dst} size={Math.max(20, editItem.dst.length + 2)} /></div>
<button on:click={() => (item.client = null)}>Delete</button> <div>Flags</div>
</td> <div><Flags bind:value={editItem.flags} editable keys={routeKeys} /></div>
</tr> <div>Active</div>
{:else} <div><input type="checkbox" bind:checked={editItem.active} /></div>
<div>Invalid redirect row: please report this error</div> </ActionPopup>
{/if} </td>
</tr>
<style lang="scss"> <style lang="scss">
tr.created {
background-color: #1a510077;
&:nth-child(2n) {
background-color: #10330077;
}
}
tr.modified {
background-color: #51510077;
&:nth-child(2n) {
background-color: #33330077;
}
}
tr.deleted {
background-color: #51000077;
&:nth-child(2n) {
background-color: #33000077;
}
}
td input[type="text"] { td input[type="text"] {
padding: 4px; padding: 4px;
} }
.desc textarea {
resize: none;
}
</style> </style>

View File

@ -0,0 +1,29 @@
<script>
import {LOGIN} from "../../utils/login";
</script>
<div class="user-popover">
<a href="https://sso.1f349.com" target="_blank">1f349 Profile</a>
<button on:click={() => LOGIN.logout()}>Log Out</button>
</div>
<style lang="scss">
@import "../../values.scss";
.user-popover {
padding: 16px 20px;
display: flex;
flex-direction: column;
button,
a {
@include button-reset;
text-align: left;
font-size: 0.9rem;
color: #10b981;
text-decoration: underline;
padding: 5px;
}
}
</style>

View File

@ -0,0 +1,17 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-shield-check"
>
<path
d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"
/>
<path d="m9 12 2 2 4-4" />
</svg>

After

Width:  |  Height:  |  Size: 458 B

View File

@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-chevron-down"
>
<path d="m6 9 6 6 6-6" />
</svg>

After

Width:  |  Height:  |  Size: 273 B

View File

@ -0,0 +1,14 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-chevron-up"
>
<path d="m18 15-6-6-6 6" />
</svg>

After

Width:  |  Height:  |  Size: 273 B

16
src/icons/Domain.svelte Normal file
View File

@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-globe"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
<path d="M2 12h20" />
</svg>

After

Width:  |  Height:  |  Size: 361 B

15
src/icons/Edit.svelte Normal file
View File

@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-pencil"
>
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
<path d="m15 5 4 4" />
</svg>

After

Width:  |  Height:  |  Size: 408 B

15
src/icons/Home.svelte Normal file
View File

@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-home"
>
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>

After

Width:  |  Height:  |  Size: 345 B

16
src/icons/Menu.svelte Normal file
View File

@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-menu"
>
<line x1="4" x2="20" y1="12" y2="12" />
<line x1="4" x2="20" y1="6" y2="6" />
<line x1="4" x2="20" y1="18" y2="18" />
</svg>

After

Width:  |  Height:  |  Size: 361 B

16
src/icons/Redirect.svelte Normal file
View File

@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-milestone"
>
<path d="M18 6H5a2 2 0 0 0-2 2v3a2 2 0 0 0 2 2h13l4-3.5L18 6Z" />
<path d="M12 13v8" />
<path d="M12 3v3" />
</svg>

After

Width:  |  Height:  |  Size: 357 B

16
src/icons/Remove.svelte Normal file
View File

@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-trash"
>
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
</svg>

After

Width:  |  Height:  |  Size: 364 B

16
src/icons/Route.svelte Normal file
View File

@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-route"
>
<circle cx="6" cy="19" r="3" />
<path d="M9 19h8.5a3.5 3.5 0 0 0 0-7h-11a3.5 3.5 0 0 1 0-7H15" />
<circle cx="18" cy="5" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 374 B

16
src/icons/Site.svelte Normal file
View File

@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-panels-top-left"
>
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M3 9h18" />
<path d="M9 21V9" />
</svg>

After

Width:  |  Height:  |  Size: 347 B

17
src/icons/Source.svelte Normal file
View File

@ -0,0 +1,17 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-github"
>
<path
d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"
/>
<path d="M9 18c-4.51 2-5-2-7-2" />
</svg>

After

Width:  |  Height:  |  Size: 546 B

17
src/icons/Status.svelte Normal file
View File

@ -0,0 +1,17 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-traffic-cone"
>
<path d="M9.3 6.2a4.55 4.55 0 0 0 5.4 0" />
<path d="M7.9 10.7c.9.8 2.4 1.3 4.1 1.3s3.2-.5 4.1-1.3" />
<path d="M13.9 3.5a1.93 1.93 0 0 0-3.8-.1l-3 10c-.1.2-.1.4-.1.6 0 1.7 2.2 3 5 3s5-1.3 5-3c0-.2 0-.4-.1-.5Z" />
<path d="m7.5 12.2-4.7 2.7c-.5.3-.8.7-.8 1.1s.3.8.8 1.1l7.6 4.5c.9.5 2.1.5 3 0l7.6-4.5c.7-.3 1-.7 1-1.1s-.3-.8-.8-1.1l-4.7-2.8" />
</svg>

After

Width:  |  Height:  |  Size: 598 B

15
src/icons/X.svelte Normal file
View File

@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-x"
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>

After

Width:  |  Height:  |  Size: 286 B

View File

@ -1,30 +0,0 @@
import {writable} from "svelte/store";
export interface Cert {
id: number;
auto_renew: boolean;
active: boolean;
renewing: boolean;
renew_failed: boolean;
not_after: string;
updated_at: string;
domains: string[];
}
export function siteEqual(a: Cert | null, b: Cert | null) {
if (a == null || b == null) return false;
a.domains.sort();
b.domains.sort();
return (
a.id == b.id &&
a.auto_renew == b.auto_renew &&
a.active == b.active &&
a.renewing == b.renewing &&
a.renew_failed == b.renew_failed &&
a.not_after == b.not_after &&
a.updated_at == b.updated_at &&
JSON.stringify(a.domains) == JSON.stringify(b.domains)
);
}
export const certsTable = writable<{[key: string]: Cert}>({});

View File

@ -1,3 +1,25 @@
import { writable } from "svelte/store"; import {derived, writable} from "svelte/store";
import {loginStore, parseJwt, type LoginStore} from "./login";
export const domainOption = writable<string>("*"); export const domainOptions = derived([loginStore], ([$loginStore]) => {
return getDomainOptions($loginStore);
});
const internalDomainOption = writable<string>(localStorage.getItem("domain-option") || "");
export const domainOption = derived([domainOptions, internalDomainOption], ([$domainOptions, $internalDomainOption]) => {
return $domainOptions.find(x => x === $internalDomainOption) || $domainOptions[0] || "";
});
function getDomainOptions(login: LoginStore | null): string[] {
let accessToken = login?.tokens?.access;
if (accessToken == null) return [];
let jwt = parseJwt(accessToken);
if (!jwt) return [];
return jwt.per.filter((x: string) => x.startsWith("domain:owns=")).map((x: string) => x.slice("domain:owns=".length));
}
export function setDomainOption(domain: string) {
internalDomainOption.set(domain);
localStorage.setItem("domain-option", domain);
}

View File

@ -33,7 +33,7 @@ export function getBearer() {
export function parseJwt(token: string) { export function parseJwt(token: string) {
const tokenParts = token.split("."); const tokenParts = token.split(".");
if(tokenParts.length !== 3) return null; if (tokenParts.length !== 3) return null;
const base64Url = tokenParts[1]; const base64Url = tokenParts[1];
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/"); const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
const jsonPayload = decodeURIComponent( const jsonPayload = decodeURIComponent(

114
src/stores/records.ts Normal file
View File

@ -0,0 +1,114 @@
import {writable} from "svelte/store";
export interface RecordHeader {
Name: string;
Rrtype: number;
Class: number;
Ttl: number;
}
export interface UnknownRecord {
Hdr: RecordHeader;
}
export interface SoaRecord extends UnknownRecord {
Ns: string;
Mbox: string;
Serial: number;
Refresh: number;
Retry: number;
Expire: number;
Minttl: number;
}
export function isSoaRecord(x: UnknownRecord): x is SoaRecord {
return x.Hdr.Rrtype === 6;
}
export const soaRecords = writable<Array<SoaRecord>>([]);
export interface NsRecord extends UnknownRecord {
Ns: string;
}
export function isNsRecord(x: UnknownRecord): x is NsRecord {
return x.Hdr.Rrtype === 2;
}
export const nsRecords = writable<Array<NsRecord>>([]);
export interface MxRecord extends UnknownRecord {
Preference: number;
Mx: string;
}
export function isMxRecord(x: UnknownRecord): x is MxRecord {
return x.Hdr.Rrtype === 15;
}
export const mxRecords = writable<Array<MxRecord>>([]);
export interface ARecord extends UnknownRecord {
A: string;
}
export function isARecord(x: UnknownRecord): x is ARecord {
return x.Hdr.Rrtype === 1;
}
export const aRecords = writable<Array<ARecord>>([]);
export interface AaaaRecord extends UnknownRecord {
AAAA: string;
}
export function isAaaaRecord(x: UnknownRecord): x is AaaaRecord {
return x.Hdr.Rrtype === 28;
}
export const aaaaRecords = writable<Array<AaaaRecord>>([]);
export interface CnameRecord extends UnknownRecord {
Target: string;
}
export function isCnameRecord(x: UnknownRecord): x is CnameRecord {
return x.Hdr.Rrtype === 5;
}
export const cnameRecords = writable<Array<CnameRecord>>([]);
export interface TxtRecord extends UnknownRecord {
Txt: Array<string>;
}
export function isTxtRecord(x: UnknownRecord): x is TxtRecord {
return x.Hdr.Rrtype === 16;
}
export const txtRecords = writable<Array<TxtRecord>>([]);
export interface SrvRecord extends UnknownRecord {
Priority: number;
Weight: number;
Port: number;
Target: string;
}
export function isSrvRecord(x: UnknownRecord): x is SrvRecord {
return x.Hdr.Rrtype === 33;
}
export const srvRecords = writable<Array<SrvRecord>>([]);
export interface CaaRecord extends UnknownRecord {
Flag: number;
Tag: string;
Value: string;
}
export function isCaaRecord(x: UnknownRecord): x is CaaRecord {
return x.Hdr.Rrtype === 257;
}
export const caaRecords = writable<Array<CaaRecord>>([]);

View File

@ -1,13 +0,0 @@
import {writable} from "svelte/store";
export interface Site {
domain: string;
branches: string[];
}
export function siteEqual(a: Site | null, b: Site | null) {
if (a == null || b == null) return false;
return a.domain == b.domain;
}
export const sitesTable = writable<{[key: string]: Site}>({});

View File

@ -1,28 +0,0 @@
import {writable} from "svelte/store";
import type {CSPair} from "../types/cspair";
import type {Pair} from "../utils/pair";
import type {Redirect, Route} from "../types/target";
export const routesTable = writable<{[key: string]: CSPair<Route>}>({});
export const redirectsTable = writable<{[key: string]: CSPair<Redirect>}>({});
function getTableArray<T>(table: {[key: string]: CSPair<T>}, keys: Array<string>): Array<Pair<string, CSPair<T>>> {
return keys.map(x => ({a: x, b: table[x]}));
}
export interface CountStats {
created: number;
modified: number;
removed: number;
}
export function tableCountStats<T>(table: {[key: string]: CSPair<T>}, keys: Array<string>, equality: (a: T, b: T) => boolean): CountStats {
let list = getTableArray(table, keys)
.map(x => x.b)
.filter(x => x.client != null || x.server != null);
return {
created: list.filter(x => x.server == null).length,
modified: list.filter(x => x.server != null && x.client != null && !equality(x.client, x.server)).length,
removed: list.filter(x => x.client == null).length,
};
}

View File

@ -1,7 +1,6 @@
export interface Route { export interface Route {
src: string; src: string;
dst: string; dst: string;
desc: string;
flags: number; flags: number;
active: boolean; active: boolean;
} }
@ -9,22 +8,11 @@ export interface Route {
export interface Redirect { export interface Redirect {
src: string; src: string;
dst: string; dst: string;
desc: string;
flags: number; flags: number;
code: number; code: number;
active: boolean; active: boolean;
} }
export function routeEqual(a: Route | null, b: Route | null): boolean {
if (a == null || b == null) return false;
return a.src === b.src && a.dst === b.dst && a.desc === b.desc && a.flags === b.flags && a.active === b.active;
}
export function redirectEqual(a: Redirect | null, b: Redirect | null): boolean {
if (a == null || b == null) return false;
return a.src === b.src && a.dst === b.dst && a.desc === b.desc && a.flags === b.flags && a.code === b.code && a.active === b.active;
}
export const routeKeys = [ export const routeKeys = [
{char: "p", name: "Prefix Path"}, {char: "p", name: "Prefix Path"},
{char: "a", name: "Absolute Path"}, {char: "a", name: "Absolute Path"},

View File

@ -1,33 +1,95 @@
import {loginStore} from "../stores/login"; import {loginStore} from "../stores/login";
import {POP2} from "./pop2";
const TOKEN_AUTHORIZE_API = import.meta.env.VITE_SSO_ORIGIN + "/authorize"; export const LOGIN = (function () {
const TOKEN_USERINFO_API = import.meta.env.VITE_SSO_ORIGIN + "/userinfo"; const OAUTH2_AUTHORIZE_API = import.meta.env.VITE_SSO_ORIGIN + "/authorize";
const OAUTH2_CLIENT_ID = import.meta.env.VITE_OAUTH2_CLIENT_ID; const OAUTH2_USERINFO_API = import.meta.env.VITE_SSO_ORIGIN + "/userinfo";
const OAUTH2_CLIENT_ID = import.meta.env.VITE_OAUTH2_CLIENT_ID;
const OAUTH2_CLIENT_SCOPE = import.meta.env.VITE_OAUTH2_CLIENT_SCOPE;
const OAUTH2_LOGOUT_PAGE = import.meta.env.VITE_LOGOUT_PAGE;
export const LOGIN = { let access_token: string | null = localStorage.getItem("oauth2_access_token"),
init: () => { redirect_uri = window.location.href.slice(0, window.location.href.length - window.location.hash.length).replace(/#$/, "");
POP2.init(TOKEN_AUTHORIZE_API, OAUTH2_CLIENT_ID, "openid profile name", 500, 600);
}, if (window.location.hash.indexOf("access_token") !== -1) {
logout: () => { access_token = window.location.hash.replace(/^.*access_token=([^&]+).*$/, "$1");
POP2.logout(); localStorage.setItem("oauth2_access_token", access_token);
}, history.pushState("", document.title, window.location.pathname + window.location.search);
clientRequest: (resource: string, options: RequestInit, refresh: boolean) => { }
return POP2.clientRequest(resource, options, refresh);
}, let hasError: boolean = false;
userinfo: (popup: boolean) => { if (window.location.search.indexOf("error=") !== -1) {
POP2.getToken((token: string) => { localStorage.removeItem("oauth2_access_token");
POP2.clientRequest(TOKEN_USERINFO_API, {}, popup) hasError = true;
}
const redirectToLogin = function () {
window.location.href =
OAUTH2_AUTHORIZE_API +
"?response_type=token" +
"&redirect_uri=" +
encodeURIComponent(redirect_uri) +
"&scope=" +
encodeURIComponent(OAUTH2_CLIENT_SCOPE) +
"&client_id=" +
encodeURIComponent(OAUTH2_CLIENT_ID);
};
const clientRequest = function (resource: RequestInfo, options: RequestInit): Promise<Response> {
const sendRequest = function (): Promise<Response> {
options.credentials = "include";
if (!options.headers) options.headers = {};
options.headers["Authorization"] = "Bearer " + access_token;
return new Promise(function (res, rej) {
fetch(resource, options)
.then(function (x) {
if (x.status >= 200 && x.status < 300) res(x);
else throw new Error("invalid status code " + x.status);
})
.catch(function (x: Error) {
if (x.message === "invalid status code 403") redirectToLogin();
rej(x);
});
});
};
if (access_token == null) {
redirectToLogin();
return Promise.reject("missing access token");
}
return sendRequest();
};
const LOGIN = {
hadError: function () {
return hasError;
},
logout: function () {
access_token = null;
loginStore.set(null);
localStorage.removeItem("login-session");
localStorage.removeItem("oauth2_access_token");
window.location.href = OAUTH2_LOGOUT_PAGE;
},
clientRequest,
userinfo: function () {
clientRequest(OAUTH2_USERINFO_API, {})
.then(x => x.json()) .then(x => x.json())
.then(x => { .then(x => {
if (access_token == null) throw new Error("missing access token");
loginStore.set({ loginStore.set({
userinfo: x, userinfo: x,
tokens: {access: token, refresh: ""}, tokens: {access: access_token, refresh: ""},
}); });
}) })
.catch(x => { .catch(x => {
console.error(x); console.error(x);
}); });
}, popup); },
}, };
};
return LOGIN;
})();

View File

@ -1,193 +0,0 @@
/* Simple OAuth 2.0 Client flow library
Author: MrMelon54, timdream
Usage:
POP2.init(client_id, scope)
Initialize the library.
redirect_uri is the current page (window.location.href).
This function should be put before Analytics so that the second click won't result a page view register.
POP2.getToken(callback)
Send access token to the callback function as the first argument.
If not logged in this triggers login popup and execute login after logged in.
Be sure to call this function in user-triggered event (such as click) to prevent popup blocker.
If not sure do use isLoggedIn() below to check first.
POP2.isLoggedIn()
boolean
*/
"use strict";
import type {RequestInfo} from "undici-types";
export const POP2 = (function (w) {
const windowName = "pop2_oauth2_login_popup";
if (window.name === windowName) {
if (window.opener && window.opener.POP2) {
if (window.location.hash.indexOf("access_token") !== -1) {
window.opener.POP2.receiveToken(
window.location.hash.replace(/^.*access_token=([^&]+).*$/, "$1"),
parseInt(window.location.hash.replace(/^.*expires_in=([^&]+).*$/, "$1")),
);
}
if (window.location.search.indexOf("error=") !== -1) {
window.opener.POP2.receiveToken("ERROR");
}
}
window.close();
}
function popupCenterScreen(url: string | URL, title: string, w: number, h: number) {
const top = (screen.availHeight - h) / 4,
left = (screen.availWidth - w) / 2;
return openWindow(url, title, `scrollbars=yes,width=${w},height=${h},top=${top},left=${left}`);
}
function openWindow(url: string | URL, winnm: string | undefined, options: string) {
const wTop = firstAvailableValue([window.screen.availTop, window.screenY, window.screenTop, 0]);
const wLeft = firstAvailableValue([window.screen.availLeft, window.screenX, window.screenLeft, 0]);
let top = 0,
left = 0,
result;
if ((result = /top=(\d+)/g.exec(options))) top = parseInt(result[1]);
if ((result = /left=(\d+)/g.exec(options))) left = parseInt(result[1]);
let w;
if (options) {
options = options.replace("top=" + top, "top=" + (top + wTop));
options = options.replace("left=" + left, "left=" + (left + wLeft));
w = window.open(url, winnm, options);
} else w = window.open(url, winnm);
return w;
}
function firstAvailableValue(arr: any[]) {
for (let i = 0; i < arr.length; i++) if (typeof arr[i] != "undefined") return arr[i];
}
let client_endpoint: string,
client_id: string,
scope = "",
redirect_uri = window.location.href.slice(0, window.location.href.length - window.location.hash.length).replace(/#$/, ""),
access_token: string | null = localStorage.getItem("pop2_access_token"),
callbackWaitForToken: ((access_token: string) => void) | undefined,
w_width = 400,
w_height = 360;
const POP2 = {
// init
init: function (f_client_endpoint: string, f_client_id: string, f_scope: string, width: number, height: number) {
if (!f_client_endpoint) return false;
if (!f_client_id) return false;
client_endpoint = f_client_endpoint;
client_id = f_client_id;
if (f_scope) scope = f_scope;
if (width) w_width = width;
if (height) w_height = height;
},
// receive token from popup
receiveToken: function (token: string | boolean | null, expires_in: number) {
if (typeof token === "string" && token !== "ERROR") {
access_token = token;
localStorage.setItem("pop2_access_token", access_token);
if (callbackWaitForToken) callbackWaitForToken(access_token);
setTimeout(function () {
access_token = null;
localStorage.removeItem("pop2_access_token");
}, expires_in * 1000);
} else if (token === false) {
callbackWaitForToken = undefined;
}
},
// pass the access token to callback
// if not logged in this triggers login popup;
// use isLoggedIn to check login first to prevent popup blocker
getToken: function (callback: (access_token: string) => void, popup = true) {
if (!client_id || !redirect_uri || !scope) {
alert("You need init() first. Check the program flow.");
return false;
}
if (access_token == null) {
if (!popup) throw Error("missing access token");
callbackWaitForToken = callback;
popupCenterScreen(
client_endpoint +
"?response_type=token" +
"&redirect_uri=" +
encodeURIComponent(redirect_uri) +
"&scope=" +
encodeURIComponent(scope) +
"&client_id=" +
encodeURIComponent(client_id),
windowName,
w_width,
w_height,
);
return false;
} else {
callback(access_token);
return true;
}
},
logout: function () {
access_token = null;
localStorage.removeItem("pop2_access_token");
},
clientRequest: function (resource: RequestInfo, options: RequestInit, refresh = false) {
const sendRequest = function () {
options.credentials = "include";
if (!options.headers) options.headers = {};
options.headers["Authorization"] = "Bearer " + access_token;
return new Promise(function (res, rej) {
fetch(resource, options)
.then(function (x) {
if (x.status >= 200 && x.status < 300) res(x);
else rej(x);
})
.catch(function (x) {
rej(["failed to send request", x]);
});
});
};
const resendRequest = function () {
return new Promise(function (res, rej) {
access_token = null;
POP2.getToken(function () {
sendRequest()
.then(function (x) {
res(x);
})
.catch(function (x) {
rej(["failed to resend request", x]);
});
});
});
};
if (!refresh) {
if (access_token == null) return Promise.reject("missing access token");
return sendRequest();
} else {
return new Promise(function (res, rej) {
sendRequest()
.then(function (x) {
res(x);
})
.catch(function () {
resendRequest()
.then(function (x) {
res(x);
})
.catch(function (x) {
rej(x);
});
});
});
}
},
};
window.POP2 = POP2;
return POP2;
})();

View File

@ -0,0 +1,6 @@
import type {Readable} from "svelte/store";
export interface IPromiseLike<T> extends Readable<T> {
isLoading(): boolean;
error(): string | null;
}

138
src/utils/rest-table.ts Normal file
View File

@ -0,0 +1,138 @@
import type {Subscriber, Unsubscriber} from "svelte/store";
import type {IPromiseLike} from "../utils/promise-like";
import {LOGIN} from "./login";
export class RestTable<T extends object> implements IPromiseLike<RestTable<T>> {
apiUrl: string;
keyFunc: (item: T) => string;
rows: Array<RestItem<T>>;
private errorReason: string | null = null;
private loading: boolean = false;
private subs: Set<Subscriber<RestTable<T>>> = new Set();
constructor(apiUrl: string, keyFunc: (item: T) => string) {
this.apiUrl = apiUrl;
this.keyFunc = keyFunc;
this.rows = [];
}
private updateSubs() {
this.subs.forEach(x => x(this));
}
isLoading(): boolean {
return this.loading;
}
error(): string | null {
return this.errorReason;
}
reload() {
(async () => {
try {
this.loading = true;
this.updateSubs();
let f = await LOGIN.clientRequest(this.apiUrl, {method: "GET"});
if (f.status != 200) throw new Error("Unexpected status code: " + f.status);
let fJson = await f.json();
let rows = fJson as T[];
this.rows = rows.map(x => new RestItem(this, x));
this.loading = false;
this.updateSubs();
} catch (err) {
console.log("Setting error reason");
console.log(err);
this.errorReason = (err as Error).message;
this.loading = false;
this.updateSubs();
}
})();
}
addItem(item: T) {
(async () => {
let f = await LOGIN.clientRequest(this.apiUrl, {method: "POST"});
if (f.status !== 200) throw new Error("Unexpected status code: " + f.status);
this.rows.push(new RestItem(this, item));
this.updateSubs();
})();
}
subscribe(run: Subscriber<RestTable<T>>): Unsubscriber {
this.subs.add(run);
run(this);
return () => this.subs.delete(run);
}
}
export class RestItem<T extends object> implements IPromiseLike<RestItem<T>> {
table: RestTable<T>;
data: T;
private errorReason: string | null = null;
private loading: boolean = false;
private subs: Set<Subscriber<RestItem<T>>> = new Set();
constructor(table: RestTable<T>, data: T) {
this.table = table;
this.data = data;
}
private updateSubs() {
this.subs.forEach(x => x(this));
}
key(): string {
return this.table.keyFunc(this.data);
}
isLoading(): boolean {
return this.loading;
}
error(): string | null {
return this.errorReason;
}
update(data: T): Promise<void> {
this.loading = true;
this.updateSubs();
return LOGIN.clientRequest(this.table.apiUrl + "/" + this.key(), {
method: "PUT",
body: JSON.stringify(this.data),
})
.then(x => {
if (x.status !== 200) throw new Error("Unexpected status code: " + x.status);
this.data = data;
this.loading = false;
this.updateSubs();
})
.catch(x => {
this.loading = false;
this.updateSubs();
});
}
remove(): Promise<void> {
this.loading = true;
this.updateSubs();
return LOGIN.clientRequest(this.table.apiUrl + "/" + this.key(), {method: "DELETE"})
.then(x => {
if (x.status !== 200) throw new Error("Unexpected status code: " + x.status);
this.table.rows = this.table.rows.filter(x => this.table.keyFunc(x.data) !== this.key());
this.loading = false;
this.updateSubs();
})
.catch(x => {
this.errorReason = "Failed to remove item " + this.table.keyFunc(this.data);
this.loading = false;
this.updateSubs();
});
}
subscribe(run: Subscriber<RestItem<T>>): Unsubscriber {
this.subs.add(run);
run(this);
return () => this.subs.delete(run);
}
}

98
src/values.scss Normal file
View File

@ -0,0 +1,98 @@
// theme
$theme-text: rgba(255, 255, 255, 0.87);
$theme-header: #202020;
$theme-sidebar: #262626;
$theme-bg: #333333;
// header
$header-height: 50px;
// sidebar
$sidebar-highlight: #047857;
$sidebar-width-short: 50px;
$sidebar-width: 232px;
// table
$table-header: $theme-header;
$table-row: $theme-sidebar;
$table-row-highlight: $theme-header;
$icon-highlight: #10b981;
@mixin button-reset {
padding: 0;
border: none;
font: inherit;
color: inherit;
background-color: transparent;
cursor: pointer;
}
@mixin button-highlight($highlight, $square: true) {
@include button-reset;
display: block;
font-size: 20px;
font-weight: 400;
line-height: 0;
width: 40px;
@if $square {
aspect-ratio: 1/1;
}
&:hover {
color: $highlight;
}
}
@mixin button-green-highlight($square: true) {
@include button-highlight(#10b981, $square);
}
@mixin button-red-highlight ($square: true) {
@include button-highlight(#ef4444, $square);
}
@mixin button-box($background: transparent) {
@include button-reset;
display: inline-flex;
-moz-box-align: center;
align-items: center;
-moz-box-pack: center;
justify-content: center;
box-sizing: border-box;
outline: 0px;
user-select: none;
vertical-align: middle;
appearance: none;
text-decoration: none;
font-weight: 500;
box-shadow: none;
border: medium;
border-radius: 1px;
cursor: pointer;
font-size: 1rem;
line-height: 1;
min-height: 34px;
min-width: 105px;
text-transform: capitalize;
transition: none;
background-color: $background;
padding: 2px 20px;
}
@mixin button-green-box {
@include button-box(#047857);
}
@mixin button-red-box {
@include button-box(#b91c1c);
}
@mixin button-green-invert-box {
@include button-box;
color: #10b981;
}

View File

@ -1,22 +1,37 @@
<script lang="ts"> <script lang="ts">
import PromiseLike from "../components/PromiseLike.svelte";
import PromiseTable from "../components/PromiseTable.svelte";
import X from "../icons/X.svelte";
import {domainOption} from "../stores/domain-option"; import {domainOption} from "../stores/domain-option";
import {type Cert, certsTable} from "../stores/certs";
import {LOGIN} from "../utils/login"; import {LOGIN} from "../utils/login";
import {RestItem, RestTable} from "../utils/rest-table";
const apiOrchid = import.meta.env.VITE_API_ORCHID; const apiOrchid = import.meta.env.VITE_API_ORCHID;
let tableKeys: string[] = []; const table = new RestTable<Cert>(apiOrchid + "/owned", (item: Cert) => item.id.toString());
$: tableKeys = Object.entries($certsTable)
.map(x => x[1]) interface Cert {
.filter(x => x.domains.map(x => domainFilter(x, $domainOption)).reduce((a, b) => a || b)) id: number;
.sort((a, b) => { auto_renew: boolean;
// sort renew failed first active: boolean;
if (a.renew_failed && b.renew_failed) return a.id - b.id; renewing: boolean;
if (a.renew_failed) return -1; renew_failed: boolean;
if (b.renew_failed) return 1; not_after: string;
return a.id - b.id; updated_at: string;
}) domains: string[];
.map(x => x.id.toString()); }
function rowOrdering(rows: RestItem<Cert>[], domain: string): RestItem<Cert>[] {
return rows
.filter(x => x.data.domains.map(x => domainFilter(x, domain)).reduce((a, b) => a || b))
.sort((a, b) => {
// sort renew failed first
if (a.data.renew_failed && b.data.renew_failed) return a.data.id - b.data.id;
if (a.data.renew_failed) return -1;
if (b.data.renew_failed) return 1;
return a.data.id - b.data.id;
});
}
function domainFilter(src: string, domain: string) { function domainFilter(src: string, domain: string) {
if (domain == "*") return true; if (domain == "*") return true;
@ -27,78 +42,64 @@
return p.endsWith(domain); return p.endsWith(domain);
} }
let promiseForTable: Promise<void> = reloadTable(); domainOption.subscribe(() => table.reload());
async function reloadTable(): Promise<void> {
let f = await LOGIN.clientRequest(apiOrchid + "/owned", {}, false);
if (f.status !== 200) throw new Error("Unexpected status code: " + f.status);
let fJson = await f.json();
let rows = fJson as Map<number, Cert>;
Object.values(rows).forEach(x => {
$certsTable[Object(x.id).toString()] = x;
});
console.log($certsTable);
}
</script> </script>
<div class="wrapper"> <h1>Certificates</h1>
<div style="padding:8px;background-color:#bb7900;">Warning: This is currently still under development</div>
<div class="scrolling-area"> <div style="padding:8px;background-color:#bb7900;">Warning: This is currently still under development</div>
{#await promiseForTable}
<div class="text-padding"> <PromiseTable value={table}>
<div>Loading...</div> <tr slot="headers">
</div> <th>ID</th>
{:then} <th>Auto Renew</th>
<table class="main-table"> <th>Active</th>
<thead> <th>Renewing</th>
<tr> <th>Renew Failed</th>
<th>ID</th> <th>Not After</th>
<th>Auto Renew</th> <th>Domains</th>
<th>Active</th> </tr>
<th>Renewing</th>
<th>Renew Failed</th> <svelte:fragment slot="rows" let:value>
<th>Not After</th> {#each rowOrdering(value.rows, $domainOption) as item}
<th>Domains</th> <PromiseLike value={item}>
</tr> <tr slot="loading" class="empty-row">
</thead> <td colspan="100">
<tbody class="invert-rows"> <div>Loading...</div>
{#each tableKeys as key (key)} </td>
{@const cert = $certsTable[key]} </tr>
<tr class:cert-error={cert.renew_failed}>
<td>{cert.id}</td> <tr slot="error" let:reason class="empty-row">
<td>{cert.auto_renew}</td> <td colspan="100">Error loading row for {item.data.id}: {reason}</td>
<td>{cert.active}</td> </tr>
<td>{cert.renewing}</td>
<td>{cert.renew_failed}</td> <tr slot="ok" let:value class:cert-error={value.data.renew_failed} class="empty-row">
<td> <td>{value.data.id}</td>
<div>{cert.not_after}</div> <td>{value.data.auto_renew}</td>
<div>{Math.round((new Date(cert.not_after).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24))} days until expiry</div> <td>{value.data.active}</td>
</td> <td>{value.data.renewing}</td>
<td class="branch-cell"> <td>{value.data.renew_failed}</td>
{#each cert.domains as domain} <td>
<div>{domain}</div> <div>{value.data.not_after}</div>
{/each} <div>{Math.round((new Date(value.data.not_after).getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24))} days until expiry</div>
</td> </td>
</tr> <td class="branch-cell">
{/each} {#each value.data.domains as domain}
</tbody> <div>{domain}</div>
</table> {/each}
{:catch err} </td>
<div class="text-padding"> </tr>
<div>Administrator... I hardly know her?</div> </PromiseLike>
<div>{err}</div> {/each}
</div> </svelte:fragment>
{/await} </PromiseTable>
</div>
</div>
<style lang="scss"> <style lang="scss">
.branch-cell { .branch-cell:last-child {
display: grid; display: grid;
grid-template-columns: repeat(1, auto); grid-template-columns: repeat(1, auto);
justify-content: center; justify-content: left;
gap: 8px; padding: 8px 15px;
} }
// css please explain yourself // css please explain yourself

View File

@ -0,0 +1,338 @@
<script lang="ts">
import {LOGIN} from "../utils/login";
import {domainOption} from "../stores/domain-option";
import {
aRecords,
aaaaRecords,
caaRecords,
cnameRecords,
isARecord,
isAaaaRecord,
isCaaRecord,
isCnameRecord,
isMxRecord,
isNsRecord,
isSoaRecord,
isSrvRecord,
isTxtRecord,
mxRecords,
nsRecords,
soaRecords,
srvRecords,
txtRecords,
type UnknownRecord,
} from "../stores/records";
import ActionMenu from "../components/ActionMenu.svelte";
const apiAzalea = import.meta.env.VITE_API_AZALEA;
let promiseForTable: Promise<void> = reloadTable();
async function reloadTable(): Promise<void> {
let f = await LOGIN.clientRequest(apiAzalea + "/domains/" + $domainOption + "/records", {});
if (f.status != 200) throw new Error("Unexpected status code: " + f.status);
let fJson = await f.json();
let rows = fJson as Array<UnknownRecord>;
$soaRecords = rows.filter(isSoaRecord);
$nsRecords = rows.filter(isNsRecord);
$mxRecords = rows.filter(isMxRecord);
$aRecords = rows.filter(isARecord);
$aaaaRecords = rows.filter(isAaaaRecord);
$cnameRecords = rows.filter(isCnameRecord);
$txtRecords = rows.filter(isTxtRecord);
$srvRecords = rows.filter(isSrvRecord);
$caaRecords = rows.filter(isCaaRecord);
}
domainOption.subscribe(x => {
promiseForTable = reloadTable();
});
function getTitleDomain(name: string): string {
if (name.endsWith(".")) {
return name.substring(0, name.length - 1);
}
return name;
}
</script>
{#await promiseForTable}
<div class="text-padding">
<div>Loading...</div>
</div>
{:then}
{#if $soaRecords.length >= 1}
<div class="title-row">
<h1>Domains / {getTitleDomain($soaRecords[0].Hdr.Name)}</h1>
<a
class="zone-download"
href="{import.meta.env.VITE_API_AZALEA}/domains/{getTitleDomain($soaRecords[0].Hdr.Name)}/zone-file"
download="{getTitleDomain($soaRecords[0].Hdr.Name)}.zone"
>
Download DNS Zone File
</a>
</div>
{/if}
<h2>SOA Record</h2>
<table class="action-table" aria-label="List of Domains SOA Record">
<thead>
<tr>
<th>Primary Domain</th>
<th>Email</th>
<th>Default TTL</th>
<th>Refresh Rate</th>
<th>Retry Rate</th>
<th>Expire Time</th>
<th></th>
</tr>
</thead>
<tbody>
{#if $soaRecords.length === 0}
<tr class="empty-row"><td colspan="7">No items to display</td></tr>
{/if}
{#each $soaRecords as record}
<tr>
<td>{record.Hdr.Name}</td>
<td>{record.Mbox}</td>
<td>{record.Minttl}</td>
<td>{record.Refresh}</td>
<td>{record.Retry}</td>
<td>{record.Expire}</td>
<td>
<ActionMenu data={record} edit={t => console.log(t)} remove={null} />
</td>
</tr>
{/each}
</tbody>
</table>
<h2>NS Record</h2>
<table class="action-table" aria-label="List of Domains NS Record">
<thead>
<tr>
<th>Name Server</th>
<th>Subdomain</th>
<th>TTL</th>
<th></th>
</tr>
</thead>
<tbody>
{#if $nsRecords.length === 0}
<tr class="empty-row"><td colspan="7">No items to display</td></tr>
{/if}
{#each $nsRecords as record}
<tr>
<td>{record.Ns}</td>
<td>{record.Hdr.Name}</td>
<td>{record.Hdr.Ttl}</td>
<td></td>
</tr>
{/each}
</tbody>
</table>
<h2>MX Record</h2>
<table class="action-table" aria-label="List of Domains MX Record">
<thead>
<tr>
<th>Mail Server</th>
<th>Preference</th>
<th>Subdomain</th>
<th>TTL</th>
<th></th>
</tr>
</thead>
<tbody>
{#if $mxRecords.length === 0}
<tr class="empty-row"><td colspan="7">No items to display</td></tr>
{/if}
{#each $mxRecords as record}
<tr>
<td>{record.Mx}</td>
<td>{record.Preference}</td>
<td>{record.Hdr.Name}</td>
<td>{record.Hdr.Ttl}</td>
<td>
<ActionMenu data={record} edit={t => console.log(t)} remove={t => console.log(t)} />
</td>
</tr>
{/each}
</tbody>
</table>
<h2>A/AAAA Record</h2>
<table class="action-table" aria-label="List of Domains A/AAAA Record">
<thead>
<tr>
<th>Hostname</th>
<th>IP Address</th>
<th>TTL</th>
<th></th>
</tr>
</thead>
<tbody>
{#if $aRecords.length === 0 && $aaaaRecords.length === 0}
<tr class="empty-row"><td colspan="7">No items to display</td></tr>
{/if}
{#each $aRecords as record}
<tr>
<td>{record.Hdr.Name}</td>
<td>{record.A}</td>
<td>{record.Hdr.Ttl}</td>
<td>
<ActionMenu data={record} edit={t => console.log(t)} remove={t => console.log(t)} />
</td>
</tr>
{/each}
{#each $aaaaRecords as record}
<tr>
<td>{record.Hdr.Name}</td>
<td>{record.AAAA}</td>
<td>{record.Hdr.Ttl}</td>
<td>
<ActionMenu data={record} edit={t => console.log(t)} remove={t => console.log(t)} />
</td>
</tr>
{/each}
</tbody>
</table>
<h2>CNAME Record</h2>
<table class="action-table" aria-label="List of Domains CNAME Record">
<thead>
<tr>
<th>Hostname</th>
<th>Aliases to</th>
<th>TTL</th>
<th></th>
</tr>
</thead>
<tbody>
{#if $cnameRecords.length === 0}
<tr class="empty-row"><td colspan="7">No items to display</td></tr>
{/if}
{#each $cnameRecords as record}
<tr>
<td>{record.Hdr.Name}</td>
<td>{record.Target}</td>
<td>{record.Hdr.Ttl}</td>
<td>
<ActionMenu data={record} edit={t => console.log(t)} remove={t => console.log(t)} />
</td>
</tr>
{/each}
</tbody>
</table>
<h2>TXT Record</h2>
<table class="action-table" aria-label="List of Domains TXT Record">
<thead>
<tr>
<th>Hostname</th>
<th>Value</th>
<th>TTL</th>
<th></th>
</tr>
</thead>
<tbody>
{#if $txtRecords.length === 0}
<tr class="empty-row"><td colspan="7">No items to display</td></tr>
{/if}
{#each $txtRecords as record}
<tr>
<td>{record.Hdr.Name}</td>
<td>{record.Txt.join("\n")}</td>
<td>{record.Hdr.Ttl}</td>
<td>
<ActionMenu data={record} edit={t => console.log(t)} remove={t => console.log(t)} />
</td>
</tr>
{/each}
</tbody>
</table>
<h2>SRV Record</h2>
<table class="action-table" aria-label="List of Domains SRV Record">
<thead>
<tr>
<th>Name</th>
<th>Priority</th>
<th>Weight</th>
<th>Port</th>
<th>Target</th>
<th>TTL</th>
<th></th>
</tr>
</thead>
<tbody>
{#if $srvRecords.length === 0}
<tr class="empty-row"><td colspan="7">No items to display</td></tr>
{/if}
{#each $srvRecords as record}
<tr>
<td>{record.Hdr.Name}</td>
<td>{record.Priority}</td>
<td>{record.Weight}</td>
<td>{record.Port}</td>
<td>{record.Target}</td>
<td>{record.Hdr.Ttl}</td>
<td>
<ActionMenu data={record} edit={t => console.log(t)} remove={t => console.log(t)} />
</td>
</tr>
{/each}
</tbody>
</table>
<h2>CAA Record</h2>
<table class="action-table" aria-label="List of Domains CAA Record">
<thead>
<tr>
<th>Name</th>
<th>Tag</th>
<th>Value</th>
<th>TTL</th>
<th></th>
</tr>
</thead>
<tbody>
{#if $caaRecords.length === 0}
<tr class="empty-row"><td colspan="7">No items to display</td></tr>
{/if}
{#each $caaRecords as record}
<tr>
<td>{record.Hdr.Name}</td>
<td>{record.Tag}</td>
<td>{record.Value}</td>
<td>{record.Hdr.Ttl}</td>
<td>
<ActionMenu data={record} edit={t => console.log(t)} remove={t => console.log(t)} />
</td>
</tr>
{/each}
</tbody>
</table>
{/await}
<style lang="scss">
@import "../values.scss";
button.action-menu {
@include button-green-highlight;
}
table tbody tr.empty-row td {
text-align: center;
}
.title-row {
display: flex;
flex-direction: row;
justify-content: space-between;
.zone-download {
@include button-green-invert-box;
}
}
</style>

View File

@ -1,17 +0,0 @@
<div class="wrapper">
<div style="padding:8px;background-color:#bb7900;">Warning: This is currently still under development</div>
<div class="button-wrapper">
<div><a class="btn-green" href="https://uptime-kuma.1f349.com" target="_blank">Status Dashboard</a></div>
<div><a class="btn-green" href="https://sso.1f349.com" target="_blank">SSO Dashboard</a></div>
<div><a class="btn-green" href="https://grafana.1f349.com" target="_blank">Grafana</a></div>
</div>
</div>
<style lang="scss">
.button-wrapper {
margin: 20px;
display: flex;
gap: 20px;
}
</style>

15
src/views/HomeView.svelte Normal file
View File

@ -0,0 +1,15 @@
<div class="wrapper">
<div style="padding:8px;background-color:#bb7900;">Warning: This is currently still under development</div>
<div class="button-wrapper">
<!-- nothing yet -->
</div>
</div>
<style lang="scss">
.button-wrapper {
margin: 20px;
display: flex;
gap: 20px;
}
</style>

View File

@ -1,23 +1,20 @@
<script lang="ts"> <script lang="ts">
import RedirectCreator from "../components/RedirectCreator.svelte";
import RedirectRow from "../components/RedirectRow.svelte"; import RedirectRow from "../components/RedirectRow.svelte";
import {redirectEqual} from "../types/target";
import {redirectsTable} from "../stores/target";
import TargetManagementView from "./TargetManagementView.svelte"; import TargetManagementView from "./TargetManagementView.svelte";
const apiViolet = import.meta.env.VITE_API_VIOLET; const apiViolet = import.meta.env.VITE_API_VIOLET;
</script> </script>
<TargetManagementView apiUrl="{apiViolet}/redirect" tableData={redirectsTable} equality={redirectEqual}> <h1>Redirects</h1>
<TargetManagementView apiUrl="{apiViolet}/redirect">
<svelte:fragment slot="headers"> <svelte:fragment slot="headers">
<th>Source</th> <th>Source</th>
<th>Destination</th> <th>Destination</th>
<th>Flags</th> <th>Flags</th>
<th>Code</th> <th>Code</th>
<th>Description</th>
<th>Active</th> <th>Active</th>
<th>Option</th> <th>Option</th>
</svelte:fragment> </svelte:fragment>
<RedirectRow slot="row" let:value {value} /> <RedirectRow slot="row" let:value {value} />
<RedirectCreator slot="creator" let:make on:make={e => make(e)} />
</TargetManagementView> </TargetManagementView>

View File

@ -1,22 +1,19 @@
<script lang="ts"> <script lang="ts">
import RouteCreator from "../components/RouteCreator.svelte";
import RouteRow from "../components/RouteRow.svelte"; import RouteRow from "../components/RouteRow.svelte";
import {routeEqual} from "../types/target";
import {routesTable} from "../stores/target";
import TargetManagementView from "./TargetManagementView.svelte"; import TargetManagementView from "./TargetManagementView.svelte";
const apiViolet = import.meta.env.VITE_API_VIOLET; const apiViolet = import.meta.env.VITE_API_VIOLET;
</script> </script>
<TargetManagementView apiUrl="{apiViolet}/route" tableData={routesTable} equality={routeEqual}> <h1>Routes</h1>
<TargetManagementView apiUrl="{apiViolet}/route">
<svelte:fragment slot="headers"> <svelte:fragment slot="headers">
<th>Source</th> <th>Source</th>
<th>Destination</th> <th>Destination</th>
<th>Flags</th> <th>Flags</th>
<th>Description</th>
<th>Active</th> <th>Active</th>
<th>Option</th> <th>Option</th>
</svelte:fragment> </svelte:fragment>
<RouteRow slot="row" let:value {value} /> <RouteRow slot="row" let:value {value} />
<RouteCreator slot="creator" let:make on:make={e => make(e)} />
</TargetManagementView> </TargetManagementView>

View File

@ -1,15 +1,23 @@
<script lang="ts"> <script lang="ts">
import PromiseLike from "../components/PromiseLike.svelte";
import PromiseTable from "../components/PromiseTable.svelte";
import RemoveIcon from "../icons/Remove.svelte";
import {domainOption} from "../stores/domain-option"; import {domainOption} from "../stores/domain-option";
import {type Site, sitesTable} from "../stores/sites";
import {LOGIN} from "../utils/login"; import {LOGIN} from "../utils/login";
import {RestItem, RestTable} from "../utils/rest-table";
const apiSiteHosting = import.meta.env.VITE_API_SITE_HOSTING; const apiSiteHosting = import.meta.env.VITE_API_SITE_HOSTING;
let tableKeys: string[] = []; const table = new RestTable<Site>(apiSiteHosting, (item: Site) => item.domain);
$: tableKeys = Object.entries($sitesTable)
.map(x => x[0]) interface Site {
.filter(x => domainFilter(x, $domainOption)) domain: string;
.sort((a, b) => a.localeCompare(b)); branches: string[];
}
function rowsDomainFilter(rows: RestItem<Site>[], domain: string): RestItem<Site>[] {
return rows.filter(x => domainFilter(x.data.domain, domain));
}
function domainFilter(src: string, domain: string) { function domainFilter(src: string, domain: string) {
if (domain == "*") return true; if (domain == "*") return true;
@ -20,112 +28,101 @@
return p.endsWith(domain); return p.endsWith(domain);
} }
let promiseForTable: Promise<void> = reloadTable();
async function reloadTable(): Promise<void> {
let f = await LOGIN.clientRequest(apiSiteHosting, {}, false);
if (f.status !== 200) throw new Error("Unexpected status code: " + f.status);
let fJson = await f.json();
let rows = fJson as Site[];
rows.forEach(x => {
$sitesTable[x.domain] = x;
});
}
async function deleteBranch(site: Site, branch: string) { async function deleteBranch(site: Site, branch: string) {
let f = await LOGIN.clientRequest( let f = await LOGIN.clientRequest(apiSiteHosting, {
apiSiteHosting, method: "POST",
{ body: JSON.stringify({submit: "delete-branch", site: site.domain, branch}),
method: "POST", });
body: JSON.stringify({submit: "delete-branch", site: site.domain, branch}),
},
false,
);
if (f.status !== 200) throw new Error("Unexpected status code: " + f.status); if (f.status !== 200) throw new Error("Unexpected status code: " + f.status);
promiseForTable = reloadTable(); table.reload();
} }
async function resetSiteSecret(site: Site) { async function resetSiteSecret(site: Site) {
let f = await LOGIN.clientRequest( let f = await LOGIN.clientRequest(apiSiteHosting, {
apiSiteHosting, method: "POST",
{ body: JSON.stringify({submit: "secret", site: site.domain}),
method: "POST", });
body: JSON.stringify({submit: "secret", site: site.domain}),
},
false,
);
if (f.status !== 200) throw new Error("Unexpected status code: " + f.status); if (f.status !== 200) throw new Error("Unexpected status code: " + f.status);
let fJson = await f.json(); let fJson = await f.json();
alert("New secret: " + fJson.secret); alert("New secret: " + fJson.secret);
} }
domainOption.subscribe(() => table.reload());
</script> </script>
<div class="wrapper"> <h1>Site Hosting</h1>
<div style="padding:8px;background-color:#bb7900;">
Warning: This is currently still under development, however it DOES send updates to the real server
</div>
<div class="scrolling-area"> <div style="padding:8px;background-color:#bb7900;">
{#await promiseForTable} Warning: This is currently still under development, however it DOES send updates to the real server
<div class="text-padding">
<div>Loading...</div>
</div>
{:then}
<table class="main-table">
<thead>
<tr>
<th>Domain</th>
<th>Branches</th>
<th>Action</th>
</tr>
</thead>
<tbody class="invert-rows">
{#each tableKeys as key (key)}
{@const site = $sitesTable[key]}
<tr>
<td><a href="https://{site.domain}" target="_blank">{site.domain}</a></td>
<td>
<div class="branch-cell">
{#each site.branches as branch}
{#if branch == ""}
<div><a href="https://{site.domain}/?git_branch=main" target="_blank" class="main-or-master">main or master</a></div>
{:else}
<div><a href="https://{site.domain}/?git_branch={branch}" target="_blank">{branch}</a></div>
{/if}
<div><button on:click={() => deleteBranch(site, branch)}>Delete Branch</button></div>
{/each}
</div>
</td>
<td><button on:click={() => resetSiteSecret(site)}>Reset Secret</button></td>
</tr>
{/each}
</tbody>
</table>
{:catch err}
<div class="text-padding">
<div>Administrator... I hardly know her?</div>
<div>{err}</div>
</div>
{/await}
</div>
</div> </div>
<PromiseTable value={table}>
<tr slot="headers">
<th>Domain</th>
<th>Branches</th>
<th>Action</th>
</tr>
<svelte:fragment slot="rows" let:value>
{#each rowsDomainFilter(value.rows, $domainOption) as item}
<PromiseLike value={item}>
<tr slot="loading" class="empty-row">
<td colspan="100">
<div>Loading...</div>
</td>
</tr>
<tr slot="error" let:reason class="empty-row">
<td colspan="100">Error loading row for {item.data.domain}: {reason}</td>
</tr>
<tr slot="ok" let:value>
<td><a href="https://{value.data.domain}" target="_blank">{value.data.domain}</a></td>
<td>
<div class="branch-cell">
{#each value.data.branches as branch}
{#if branch == ""}
<div><a href="https://{value.data.domain}/?git_branch=main" target="_blank" class="main-or-master">main or master</a></div>
{:else}
<div><a href="https://{value.data.domain}/?git_branch={branch}" target="_blank">{branch}</a></div>
{/if}
<div><button class="btn-trash" on:click={() => deleteBranch(value.data, branch)}><RemoveIcon /></button></div>
{/each}
</div>
</td>
<td><button class="btn-reset-secret" on:click={() => resetSiteSecret(value.data)}>Reset Secret</button></td>
</tr>
</PromiseLike>
{/each}
</svelte:fragment>
</PromiseTable>
<style lang="scss"> <style lang="scss">
.main-table { @import "../values.scss";
th,
td {
width: 1%;
}
}
.branch-cell { .branch-cell {
display: grid; display: grid;
grid-template-columns: repeat(2, auto); grid-template-columns: repeat(2, auto);
justify-content: center; justify-content: left;
align-content: center;
gap: 8px; gap: 8px;
div a {
display: block;
height: 40px;
line-height: 40px;
}
} }
.main-or-master { .main-or-master {
color: lightgreen; color: lightgreen;
} }
.btn-trash {
@include button-red-highlight;
}
.btn-reset-secret {
@include button-red-box;
}
</style> </style>

View File

@ -5,166 +5,56 @@
</script> </script>
<script lang="ts"> <script lang="ts">
import {LOGIN} from "../utils/login";
import {writable, type Writable} from "svelte/store";
import type {CSPair} from "../types/cspair";
import {domainOption} from "../stores/domain-option"; import {domainOption} from "../stores/domain-option";
import {type CountStats, tableCountStats} from "../stores/target"; import {RestItem, RestTable} from "../utils/rest-table";
import PromiseTable from "../components/PromiseTable.svelte";
import PromiseLike from "../components/PromiseLike.svelte";
type T = $$Generic<Target>; type T = $$Generic<Target>;
export let apiUrl: string; export let apiUrl: string;
export let tableData: Writable<{[key: string]: CSPair<T>}>;
export let equality: (a: T | null, b: T | null) => boolean;
let tableSearch: string = ""; let table = new RestTable<T>(apiUrl, (item: T) => item.src);
let tableKeys: string[] = []; function rowsDomainFilter(rows: RestItem<T>[], domain: string): RestItem<T>[] {
$: tableKeys = Object.entries($tableData) return rows.filter(x => domainFilter(x.data, domain));
.filter(x => x[1].client != null || x[1].server != null) }
.map(x => x[0])
.filter(x => domainFilter(x, $domainOption))
.filter(x => x.includes(tableSearch))
.sort((a, b) => a.localeCompare(b));
let rowStats: CountStats = {created: 0, modified: 0, removed: 0}; function domainFilter(item: T, domain: string): boolean {
$: rowStats = tableCountStats($tableData, tableKeys, equality); let n = item.src.indexOf("/");
if (n == -1) n = item.src.length;
function domainFilter(src: string, domain: string) { let p = item.src.slice(0, n);
if (domain == "*") return true;
let n = src.indexOf("/");
if (n == -1) n = src.length;
let p = src.slice(0, n);
if (p == domain) return true; if (p == domain) return true;
return p.endsWith(domain); return p.endsWith(domain);
} }
let promiseForTable: Promise<void> = reloadTable(); domainOption.subscribe(() => table.reload());
async function reloadTable(): Promise<void> {
let f = await LOGIN.clientRequest(apiUrl, {}, false);
if (f.status !== 200) throw new Error("Unexpected status code: " + f.status);
let fJson = await f.json();
let rows = fJson as T[];
let srcs = new Set(Object.keys($tableData));
rows.forEach(x => {
$tableData[x.src] = {
client: !$tableData[x.src] ? JSON.parse(JSON.stringify(x)) : $tableData[x.src]?.client,
server: x,
p: Promise.resolve(),
};
srcs.delete(x.src);
});
srcs.forEach(x => {
$tableData[x].server = null;
});
}
interface Savable<T> {
type: "del" | "ins";
v: CSPair<T>;
}
function saveChanges() {
let tableProm = tableKeys
.map(x => $tableData[x])
.filter(x => x.client != null || x.server != null)
.filter(x => !equality(x.client, x.server))
.map((x: CSPair<T>): Savable<T> => {
if (x.client == null && x.server != null) return {type: "del", v: x};
return {type: "ins", v: x};
})
.sort((a, _) => (a.type === "del" ? -1 : a.type === "ins" ? 1 : 0))
.map(x => {
x.v.p = LOGIN.clientRequest(
apiUrl,
{
method: x.type == "del" ? "DELETE" : "POST",
body: JSON.stringify(x.type == "del" ? {src: (x.v.server as T).src} : x.v.client),
},
false,
).then(x => {
if (x.status !== 200) throw new Error("Unexpected status code: " + x.status);
});
return x.v.p;
});
Promise.all(tableProm)
.then(_ => reloadTable())
.catch(_ => {
alert("Some rows failed to save changes");
});
}
export function make(e: CustomEvent<T>) {
const x = e.detail as unknown as T;
$tableData[x.src] = {client: x, server: $tableData[x.src]?.server, p: Promise.resolve()};
tableKeys.push(x.src);
tableKeys = tableKeys;
}
</script> </script>
<div class="wrapper"> <div style="padding:8px;background-color:#bb7900;">
<div style="padding:8px;background-color:#bb7900;"> Warning: This is currently still under development, however it DOES send updates to the real server
Warning: This is currently still under development, however it DOES send updates to the real server
</div>
<div id="search-wrapper">
<label>
Search: <input type="search" name="table-search" bind:value={tableSearch} />
</label>
</div>
<div class="scrolling-area">
{#await promiseForTable}
<div class="text-padding">
<div>Loading...</div>
</div>
{:then}
<table class="main-table">
<thead>
<tr>
<slot name="headers" />
</tr>
<slot name="creator" {make} />
</thead>
<tbody>
{#each tableKeys as src (src)}
{#await $tableData[src].p}
<tr><td colspan="5">Loading...</td></tr>
{:then _}
<slot name="row" value={writable($tableData[src])} />
{:catch err}
<tr><td colspan="5">Error loading row for {src}: {err}</td></tr>
{/await}
{/each}
</tbody>
</table>
{:catch err}
<div class="text-padding">
<div>Administrator... I hardly know her?</div>
<div>{err}</div>
</div>
{/await}
</div>
<div class="footer">
<button class="btn-green" on:click={() => saveChanges()}>Save Changes</button>
{#if rowStats.created > 0}
<div class="meta-info">{rowStats.created} new row{rowStats.created > 1 ? "s" : ""}</div>
{/if}
{#if rowStats.modified > 0}
<div class="meta-info">{rowStats.modified} unsaved change{rowStats.modified > 1 ? "s" : ""}</div>
{/if}
{#if rowStats.removed > 0}
<div class="meta-info">{rowStats.removed} removed row{rowStats.removed > 1 ? "s" : ""}</div>
{/if}
</div>
</div> </div>
<style> <PromiseTable value={table}>
#search-wrapper { <tr slot="headers">
padding: 16px; <slot name="headers" />
} </tr>
</style>
<svelte:fragment slot="rows" let:value>
{#each rowsDomainFilter(value.rows, $domainOption) as item}
<PromiseLike value={item}>
<tr slot="loading" class="empty-row">
<td colspan="100">
<div>Loading...</div>
</td>
</tr>
<tr slot="error" let:reason class="empty-row">
<td colspan="100">Error loading row for {item.data.src}: {reason}</td>
</tr>
<slot name="row" slot="ok" let:value {value} />
</PromiseLike>
{/each}
</svelte:fragment>
</PromiseTable>

8
src/vite-env.d.ts vendored
View File

@ -2,9 +2,17 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
interface ImportMetaEnv { interface ImportMetaEnv {
VITE_APP_VERSION: string;
VITE_APP_LASTMOD: string;
VITE_SSO_ORIGIN: string; VITE_SSO_ORIGIN: string;
VITE_OAUTH2_CLIENT_ID: string;
VITE_OAUTH2_CLIENT_SCOPE: string;
VITE_LOGOUT_PAGE: string;
VITE_API_VIOLET: string; VITE_API_VIOLET: string;
VITE_API_ORCHID: string; VITE_API_ORCHID: string;
VITE_API_AZALEA: string;
VITE_API_SITE_HOSTING: string;
} }
interface ImportMeta { interface ImportMeta {

View File

@ -79,7 +79,7 @@ func ssoServer(signer mjwt.Signer) {
r.HandleFunc("/userinfo", func(w http.ResponseWriter, r *http.Request) { r.HandleFunc("/userinfo", func(w http.ResponseWriter, r *http.Request) {
corsAccessControl.ServeHTTP(w, r, func(w http.ResponseWriter, r *http.Request) { corsAccessControl.ServeHTTP(w, r, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"aud":"b5a9a8df-827c-4925-b1c1-1940abcf356b","name":"Test User","picture":"","profile":"http://localhost:9090/user/test-user","sub":"b429562a-20e9-4466-9e8e-bdeb55f2f4a3@localhost","updated_at":1572278406,"website":""}`)) w.Write([]byte(`{"aud":"b5a9a8df-827c-4925-b1c1-1940abcf356b","name":"Test User","picture":"https://picsum.photos/id/392/200","profile":"http://localhost:9090/user/test-user","sub":"b429562a-20e9-4466-9e8e-bdeb55f2f4a3@localhost","updated_at":1572278406,"website":""}`))
}) })
}) })
log.Println("[SSO Server]", http.ListenAndServe(":9090", r)) log.Println("[SSO Server]", http.ListenAndServe(":9090", r))
@ -103,21 +103,29 @@ var serveApiCors = cors.New(cors.Options{
}) })
func apiServer(verify mjwt.Verifier) { func apiServer(verify mjwt.Verifier) {
subdomains := []string{
"",
"www.",
"admin.",
"staff.",
"sso.",
"login.",
"status.",
}
r := http.NewServeMux() r := http.NewServeMux()
r.Handle("/v1/violet/route", hasPerm(verify, "violet:route", func(rw http.ResponseWriter, req *http.Request) { r.Handle("/v1/violet/route", hasPerm(verify, "violet:route", func(rw http.ResponseWriter, req *http.Request) {
m := make([]map[string]any, 0, 40) m := make([]map[string]any, 0, len(subdomains)*2)
for i := 0; i < 20; i++ { for _, i := range subdomains {
m = append(m, map[string]any{ m = append(m, map[string]any{
"src": uuid.NewString() + ".example.com", "src": i + "example.com",
"dst": "127.0.0.1:8080", "dst": "127.0.0.1:8080",
"desc": "This is a test description", "desc": "This is a test description",
"flags": 181, "flags": 181,
"active": true, "active": true,
}) })
}
for i := 0; i < 20; i++ {
m = append(m, map[string]any{ m = append(m, map[string]any{
"src": uuid.NewString() + ".example.org", "src": i + "example.org",
"dst": "127.0.0.1:8085", "dst": "127.0.0.1:8085",
"desc": "This is a test description", "desc": "This is a test description",
"flags": 17, "flags": 17,
@ -127,20 +135,18 @@ func apiServer(verify mjwt.Verifier) {
json.NewEncoder(rw).Encode(m) json.NewEncoder(rw).Encode(m)
})) }))
r.Handle("/v1/violet/redirect", hasPerm(verify, "violet:redirect", func(rw http.ResponseWriter, req *http.Request) { r.Handle("/v1/violet/redirect", hasPerm(verify, "violet:redirect", func(rw http.ResponseWriter, req *http.Request) {
m := make([]map[string]any, 0, 40) m := make([]map[string]any, 0, len(subdomains)*2)
for i := 0; i < 20; i++ { for _, i := range subdomains {
m = append(m, map[string]any{ m = append(m, map[string]any{
"src": uuid.NewString() + ".example.com", "src": i + "example.com",
"dst": "test1.example.com", "dst": "test1.example.com",
"desc": "This is a test description", "desc": "This is a test description",
"flags": 1, "flags": 1,
"code": 308, "code": 308,
"active": true, "active": true,
}) })
}
for i := 0; i < 20; i++ {
m = append(m, map[string]any{ m = append(m, map[string]any{
"src": uuid.NewString() + ".example.org", "src": i + "example.org",
"dst": "test2.example.org", "dst": "test2.example.org",
"desc": "This is a test description", "desc": "This is a test description",
"flags": 3, "flags": 3,
@ -151,10 +157,10 @@ func apiServer(verify mjwt.Verifier) {
json.NewEncoder(rw).Encode(m) json.NewEncoder(rw).Encode(m)
})) }))
r.Handle("/v1/orchid/owned", hasPerm(verify, "orchid:cert", func(rw http.ResponseWriter, req *http.Request) { r.Handle("/v1/orchid/owned", hasPerm(verify, "orchid:cert", func(rw http.ResponseWriter, req *http.Request) {
m := make(map[int]any, 41) m := make([]map[string]any, 0, len(subdomains)*2)
for i := 0; i < 20; i++ { for i := 0; i < len(subdomains); i++ {
u := uuid.NewString() u := subdomains[i] + "example.com"
m[i] = map[string]any{ m = append(m, map[string]any{
"id": i + 1, "id": i + 1,
"auto_renew": true, "auto_renew": true,
"active": true, "active": true,
@ -163,14 +169,12 @@ func apiServer(verify mjwt.Verifier) {
"not_after": "2024-02-06T11:52:05Z", "not_after": "2024-02-06T11:52:05Z",
"updated_at": "2023-11-08T07:32:08Z", "updated_at": "2023-11-08T07:32:08Z",
"domains": []string{ "domains": []string{
u + ".example.com", u,
"*." + u + ".example.com", "*." + u,
}, },
} })
} u = subdomains[i] + "example.org"
for i := 0; i < 20; i++ { m = append(m, map[string]any{
u := uuid.NewString()
m[i+20] = map[string]any{
"id": i + 21, "id": i + 21,
"auto_renew": false, "auto_renew": false,
"active": false, "active": false,
@ -179,27 +183,153 @@ func apiServer(verify mjwt.Verifier) {
"not_after": "2024-02-06T11:52:05Z", "not_after": "2024-02-06T11:52:05Z",
"updated_at": "2023-11-08T07:32:08Z", "updated_at": "2023-11-08T07:32:08Z",
"domains": []string{ "domains": []string{
u + ".example.org", u,
"*." + u + ".example.org", "*." + u,
}, },
} })
}
u := uuid.NewString()
m[40] = map[string]any{
"id": 41,
"auto_renew": false,
"active": false,
"renewing": false,
"renew_failed": true,
"not_after": "2024-02-06T11:52:05Z",
"updated_at": "2023-11-08T07:32:08Z",
"domains": []string{
u + ".example.org",
"*." + u + ".example.org",
},
} }
json.NewEncoder(rw).Encode(m) json.NewEncoder(rw).Encode(m)
})) }))
r.Handle("/v1/azalea/domains", hasPerm(verify, "domains:manage", func(rw http.ResponseWriter, req *http.Request) {
type Zone struct {
ID int64 `json:"id"`
Name string `json:"name"`
}
json.NewEncoder(rw).Encode([]Zone{
{ID: 1, Name: "example.com."},
{ID: 2, Name: "example.org."},
})
}))
r.Handle("/v1/azalea/domains/example.com/records", hasPerm(verify, "domains:manage", func(rw http.ResponseWriter, req *http.Request) {
fmt.Fprintln(rw, `[
{
"Hdr": {
"Name": "example.com.",
"Rrtype": 6,
"Class": 1,
"Ttl": 300,
"Rdlength": 0
},
"Ns": "ns1.example.com.",
"Mbox": "hostmaster.example.com.",
"Serial": 2024062001,
"Refresh": 7200,
"Retry": 1800,
"Expire": 1209600,
"Minttl": 300
},
{
"Hdr": {
"Name": "example.com.",
"Rrtype": 2,
"Class": 1,
"Ttl": 300,
"Rdlength": 0
},
"Ns": "ns1.example.com."
},
{
"Hdr": {
"Name": "example.com.",
"Rrtype": 2,
"Class": 1,
"Ttl": 300,
"Rdlength": 0
},
"Ns": "ns2.example.com."
},
{
"Hdr": {
"Name": "example.com.",
"Rrtype": 2,
"Class": 1,
"Ttl": 300,
"Rdlength": 0
},
"Ns": "ns3.example.com."
},
{
"Hdr": {
"Name": "ns1.example.com.",
"Rrtype": 1,
"Class": 1,
"Ttl": 300,
"Rdlength": 0
},
"A": "10.54.0.1"
}
]`)
}))
r.Handle("/v1/azalea/domains/example.org/records", hasPerm(verify, "domains:manage", func(rw http.ResponseWriter, req *http.Request) {
fmt.Fprintln(rw, `[
{
"Hdr": {
"Name": "example.org.",
"Rrtype": 6,
"Class": 1,
"Ttl": 300,
"Rdlength": 0
},
"Ns": "ns1.example.com.",
"Mbox": "hostmaster.example.com.",
"Serial": 2024062001,
"Refresh": 7200,
"Retry": 1800,
"Expire": 1209600,
"Minttl": 300
},
{
"Hdr": {
"Name": "example.org.",
"Rrtype": 2,
"Class": 1,
"Ttl": 300,
"Rdlength": 0
},
"Ns": "ns1.example.com."
},
{
"Hdr": {
"Name": "example.org.",
"Rrtype": 2,
"Class": 1,
"Ttl": 300,
"Rdlength": 0
},
"Ns": "ns2.example.com."
},
{
"Hdr": {
"Name": "example.org.",
"Rrtype": 2,
"Class": 1,
"Ttl": 300,
"Rdlength": 0
},
"Ns": "ns3.example.com."
},
{
"Hdr": {
"Name": "example.org.",
"Rrtype": 1,
"Class": 1,
"Ttl": 300,
"Rdlength": 0
},
"A": "10.36.0.1"
},
{
"Hdr": {
"Name": "example.org.",
"Rrtype": 28,
"Class": 1,
"Ttl": 300,
"Rdlength": 0
},
"AAAA": "2001:db8::15"
}
]`)
}))
r.Handle("/v1/sites", hasPerm(verify, "sites:manage", func(rw http.ResponseWriter, req *http.Request) { r.Handle("/v1/sites", hasPerm(verify, "sites:manage", func(rw http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodPost { if req.Method == http.MethodPost {
defer req.Body.Close() defer req.Body.Close()
@ -219,16 +349,14 @@ func apiServer(verify mjwt.Verifier) {
} }
return return
} }
m := make([]any, 0, 40) m := make([]any, 0, len(subdomains))
for i := 0; i < 20; i++ { for _, i := range subdomains {
m = append(m, map[string]any{ m = append(m, map[string]any{
"domain": uuid.NewString() + ".example.com", "domain": i + "example.com",
"branches": []string{"", "beta"}, "branches": []string{"", "beta"},
}) })
}
for i := 0; i < 20; i++ {
m = append(m, map[string]any{ m = append(m, map[string]any{
"domain": uuid.NewString() + ".example.org", "domain": i + "example.org",
"branches": []string{"", "alpha"}, "branches": []string{"", "alpha"},
}) })
} }