container page

This commit is contained in:
Hamcha 2024-04-17 00:11:09 +02:00
parent 2d9e0ef66e
commit fc00b1795a
Signed by: hamcha
GPG Key ID: 1669C533B8CF6D89
14 changed files with 4553 additions and 28 deletions

3950
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -12,27 +12,29 @@
"format": "prettier --write ."
},
"devDependencies": {
"@fontsource-variable/inter": "^5.0.17",
"@fontsource/iosevka": "^5.0.7",
"@iconify-json/ri": "^1.1.20",
"@sveltejs/adapter-auto": "^3.2.0",
"@sveltejs/kit": "^2.5.5",
"@sveltejs/kit": "^2.5.6",
"@sveltejs/vite-plugin-svelte": "^3.1.0",
"@types/eslint": "^8.56.9",
"@typescript-eslint/eslint-plugin": "^7.6.0",
"@typescript-eslint/parser": "^7.6.0",
"eslint": "^9.0.0",
"@types/eventsource": "^1.1.15",
"@typescript-eslint/eslint-plugin": "^7.7.0",
"@typescript-eslint/parser": "^7.7.0",
"ansi-to-html": "^0.7.2",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.37.0",
"eventsource": "^2.0.2",
"prettier": "^3.2.5",
"prettier-plugin-svelte": "^3.2.3",
"svelte": "^4.2.14",
"svelte-check": "^3.6.9",
"tslib": "^2.6.2",
"typescript": "^5.4.5",
"vite": "^5.2.8",
"@fontsource-variable/inter": "^5.0.17",
"@fontsource/iosevka": "^5.0.7",
"unplugin-icons": "^0.18.5",
"@iconify-json/ri": "^1.1.20"
"vite": "^5.2.9"
},
"type": "module",
"dependencies": {}
"type": "module"
}

13
src/lib/debounce.ts Normal file
View File

@ -0,0 +1,13 @@
export default function debounce<C, T extends (...args: any[]) => any>(
this: C,
func: T,
timeout = 100
) {
let timer: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timer);
timer = setTimeout(() => {
func.apply(this, args);
}, timeout);
};
}

View File

@ -36,3 +36,13 @@ export async function getContainerList(): Promise<ContainerInfo[]> {
const res = await fetchAPI('/containers');
return res.json();
}
export async function getContainerInfo(name: string): Promise<ContainerInfo> {
const res = await fetchAPI(`/containers/${name}`);
return res.json();
}
export async function getContainerLogs(name: string): Promise<string> {
const res = await fetchAPI(`/containers/${name}/logs`);
return res.text();
}

View File

@ -1,8 +1,9 @@
<script lang="ts">
export let title: string | undefined = undefined;
export let fill: boolean = false;
</script>
<section class="panel">
<section class="panel" class:fill>
{#if title}
<header>
{title}
@ -14,12 +15,19 @@
<style scoped>
.panel {
background-color: var(--neutral-raised);
border-radius: 3px;
border-radius: 5px;
padding: var(--padding-normal);
& header {
& > header {
margin-bottom: var(--padding-normal);
font-weight: bold;
font-weight: normal;
font-size: 1.2rem;
}
}
.fill {
height: 100%;
display: flex;
flex-direction: column;
}
</style>

View File

@ -1,4 +1,4 @@
<script>
<script lang="ts">
import '@fontsource-variable/inter';
</script>
@ -6,11 +6,41 @@
<title>staxman</title>
</svelte:head>
<nav>
<a href="/">Overview</a>
</nav>
<main>
<slot />
</main>
<style>
<style scoped>
nav {
background-color: var(--neutral-raised);
display: flex;
& a {
color: var(--neutral-text-dark);
text-decoration: none;
border-bottom: 1px solid var(--neutral-border);
display: block;
padding: var(--padding-normal) var(--padding-wide);
&:hover {
color: var(--neutral-text);
border-bottom-color: var(--neutral-border-bright);
}
}
}
main {
padding: var(--padding-normal);
}
:global(body) {
padding: 0;
margin: 0;
}
:global(:root) {
/* Radix color: Iris */
--parpol-dark: #13131e;
@ -21,16 +51,21 @@
--neutral-dark: #121113;
--neutral-raised: #1a191b;
--neutral-text: #eeeef0;
--neutral-text-dark: #b5b2bc;
--neutral-text-darker: #7c7a85;
--neutral-border-bright: #625f69;
--neutral-border: #49474e;
--text-bright: #f9eaff;
--text-accent: #e796f3;
--text-bright: #ecd9fa;
--text-accent: #d19dff;
--table-border-color: #625f69;
--table-border: 3px solid var(--table-border-color);
--table-border: 2px solid var(--table-border-color);
--table-bg: #232225;
--table-border-radius: 5px;
--link: #ffc53d;
--link-hover: #e28d0e;
--link: #ffd769;
--link-hover: #e79f31;
--button-color: #b1a9ff;
--button-bg: #202248;
@ -56,10 +91,25 @@
color: var(--neutral-text);
font-family: var(--font-sans);
scrollbar-width: thin;
padding: 0;
margin: 0;
}
:global(p) {
margin: 0;
padding: 0;
}
:global(a) {
color: var(--link);
&:hover {
color: var(--link-hover);
}
}
:global(code),
:global(pre) {
font-family: var(--font-monospace);
}
</style>

View File

@ -6,5 +6,14 @@
export let data: PageData;
</script>
<SystemInfo data={data.systemInfo} />
<ContainerList data={data.containers} />
<main>
<SystemInfo data={data.systemInfo} />
<ContainerList data={data.containers} />
</main>
<style scoped>
main {
display: grid;
gap: var(--padding-normal);
}
</style>

View File

@ -1,11 +1,73 @@
<script lang="ts">
import type { ContainerInfo } from '$lib/server/containers';
import Panel from '$lib/ui/Panel.svelte';
export let data: ContainerInfo[];
const sortedContainers = data.slice().sort((a, b) => {
if (a.state !== b.state) {
const states = ['running', 'restarting', 'created', 'removing', 'paused', 'exited', 'dead'];
return states.indexOf(a.state) - states.indexOf(b.state);
}
return a.name.localeCompare(b.name);
});
</script>
<ul>
{#each data as container}
<li>{container.name}</li>
{/each}
</ul>
<Panel title="Containers">
<table>
<thead>
<tr>
<th>Name</th>
<th>Project</th>
<th>Status</th>
<th>Image</th>
</tr>
</thead>
<tbody>
{#each sortedContainers as container}
<tr>
<td><a href="/container/{container.name}">{container.name}</a></td>
<td>{container.labels?.['com.docker.compose.project'] ?? '-'}</td>
<td
class="status"
class:running={container.state === 'running'}
class:exited={container.state === 'exited' || container.state === 'dead'}
>{container.state}</td
>
<td>{container.image}</td>
</tr>
{/each}
</tbody>
</table>
</Panel>
<style scoped>
table {
border: var(--table-border);
border-radius: var(--table-border-radius);
}
th {
background-color: var(--table-bg);
}
th,
td {
padding: var(--padding-narrow);
}
.status {
text-align: center;
font-weight: bold;
color: white;
padding: 0 var(--padding-normal);
&.running {
background-color: var(--success-bright);
}
&.exited {
background-color: var(--danger-bright);
}
}
</style>

View File

@ -91,6 +91,7 @@
text-align: center;
& > header {
font-weight: bold;
width: 100%;
display: flex;
justify-content: center;

View File

@ -0,0 +1,8 @@
import { getContainerInfo, getContainerLogs } from '$lib/server/containers';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
return {
info: await getContainerInfo(params.name)
};
};

View File

@ -0,0 +1,88 @@
<script lang="ts">
import { page } from '$app/stores';
import Panel from '$lib/ui/Panel.svelte';
import type { PageData } from './$types';
import InfoBox from './InfoBox.svelte';
import Logs from './Logs.svelte';
export let data: PageData;
const name = data.info.name;
const project = data.info.labels?.['com.docker.compose.project'];
</script>
<main>
<header>
<h1>
Container <span title={name} class="truncate name-hl">{name}</span>
{#if project}<span title={project} class="truncate project-hl">({project})</span>{/if}
</h1>
<div class="actions"></div>
</header>
<section class="logs">
<Panel fill title="Logs">
<Logs container_name={$page.params.name} />
</Panel>
</section>
<section class="info">
<Panel fill title="Info">
<InfoBox info={data.info} />
</Panel>
</section>
</main>
<style scoped>
main {
display: grid;
gap: var(--padding-normal);
grid-template-areas: 'header' 'info' 'logs';
grid-template-columns: 1fr;
max-width: 100vw;
}
header {
grid-area: header;
& h1 {
display: flex;
gap: 0.5em;
}
}
.logs {
grid-area: logs;
}
.info {
grid-area: info;
}
.truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: inline-block;
}
.name-hl {
color: var(--text-accent);
}
.project-hl {
color: var(--text-bright);
}
@media (min-width: 1000px) {
main {
--header-height: 120px;
height: calc(100vh - 80px);
grid-template-areas:
'header header'
'info logs';
grid-template-rows:
var(--header-height)
calc(100% - var(--header-height) - var(--padding-normal));
grid-template-columns: 30vw 1fr;
}
}
</style>

View File

@ -0,0 +1,163 @@
<script lang="ts">
import type { ContainerInfo } from '$lib/server/containers';
export let info: ContainerInfo;
</script>
<section class="scroll info">
<dl class="info-set">
<dt>ID</dt>
<dd>{info.id}</dd>
<dt>Status</dt>
<dd
class="status"
class:running={info.state === 'running'}
class:exited={info.state === 'exited' || info.state === 'dead'}
>
{info.state}
</dd>
<dt>Image</dt>
<dd>{info.image}</dd>
<dt>Image ID</dt>
<dd>{info.image_id}</dd>
<dt>Created</dt>
<dd>{info.created_at}</dd>
</dl>
{#if info.volumes}
<header>Volumes</header>
<dl class="volume-list">
{#each info.volumes as volume}
<dt>{volume.Source}</dt>
<dd>
<b title={[volume.Type, volume.Mode, volume.Name].filter(Boolean).join(' ')}
>{volume.Destination}</b
>
</dd>
{/each}
</dl>
{/if}
{#if info.env}
<header>Environment</header>
<ul class="env">
{#each info.env as env}
<li>{env}</li>
{/each}
</ul>
{/if}
{#if info.labels}
<header>Labels</header>
<dl class="label-list">
{#each Object.entries(info.labels) as [key, value]}
<dt>{key}</dt>
<dd>
{#if value}{value}{:else}<i>[empty]</i>{/if}
</dd>
{/each}
</dl>
{/if}
</section>
<style scoped>
header:not(:first-child) {
padding-top: var(--padding-normal);
font-weight: bold;
font-size: 0.8rem;
text-transform: uppercase;
margin: var(--padding-normal) 0;
color: var(--text-accent);
}
dl,
ul {
margin: var(--padding-normal) 0;
}
.info-set {
display: grid;
gap: var(--padding-normal);
grid-template-columns: 80px 1fr;
& dt {
font-weight: bold;
font-size: 0.8rem;
text-transform: uppercase;
display: flex;
align-items: center;
color: var(--text-accent);
}
& dd {
margin: 0;
word-break: break-all;
display: flex;
}
}
.volume-list,
.label-list {
& dd,
& dt {
word-break: break-word;
display: flex;
gap: 1rem;
& b {
white-space: nowrap;
flex: 1;
}
}
}
.label-list {
margin: 0;
& dt {
font-weight: bold;
color: var(--text-bright);
}
& dd {
margin: var(--padding-narrow) 0;
word-break: break-all;
padding-bottom: var(--padding-normal);
font-family: var(--font-monospace);
}
}
.env {
list-style: none;
padding: 0;
margin: 0;
& li {
word-break: break-all;
padding: var(--padding-narrow);
&:nth-child(odd) {
background-color: var(--table-bg);
}
}
}
.scroll {
height: 100%;
overflow-y: auto;
padding-right: var(--padding-normal);
}
.status {
text-align: center;
font-weight: bold;
color: white;
padding: var(--padding-narrow) var(--padding-normal);
border-radius: 5px;
&.running {
background-color: var(--success-bg);
color: var(--success-text);
}
&.exited {
background-color: var(--danger-bg);
color: var(--danger-text);
}
}
</style>

View File

@ -0,0 +1,124 @@
<script lang="ts">
import { browser } from '$app/environment';
import debounce from '$lib/debounce';
import ansiFmt from 'ansi-to-html';
import type { Action } from 'svelte/action';
export let container_name: string;
const convert = new ansiFmt({
fg: '#E0DFFE',
bg: '#171625',
newline: false,
escapeXML: true,
stream: false,
colors: [
'#282c34',
'#abb2bf',
'#e06c75',
'#be5046',
'#98c379',
'#d19a66',
'#61afef',
'#c678dd',
'#56b6c2',
'#4b5263',
'#5c6370'
]
});
let logLines: { timestamp: string; logline: string }[] = [];
let logError: string;
let container: HTMLElement;
let fetched = false;
const forceRerender = debounce(() => {
fetched = true;
logLines = logLines;
}, 100);
const scrollToBottom = debounce(() => {
container.lastElementChild?.scrollIntoView({ behavior: 'instant' });
}, 100);
const setLastElement: Action<HTMLElement> = (node) => {
scrollToBottom();
};
async function getLogs() {
// Start fetching logs
let logSource = new EventSource(`/container/${container_name}/logs`);
logSource.addEventListener('error', () => {
logSource.close();
logError = 'No logs available after this (container not running)';
});
logSource.addEventListener('message', (ev) => {
const data = JSON.parse(ev.data);
// If an error is received stop listening
if ('error' in data) {
logSource.close();
logError = data.error;
return;
}
// Received lines of log
if ('lines' in data) {
data.lines.split('\n').forEach((line: string) => {
if (!line) {
return;
}
// Extract timestamp
const firstSpace = line.indexOf(' ');
const [timestamp, logline] = [
line.substring(0, firstSpace),
line.substring(firstSpace + 1)
];
// Push to log list
logLines.push({ timestamp, logline });
// Force rerender after delay
forceRerender();
});
}
});
}
// After this point, begone server
if (browser) {
getLogs();
}
</script>
<code class="logs" bind:this={container}>
{#each logLines as line (line.timestamp)}
<p use:setLastElement>
<time datetime={line.timestamp}>{line.timestamp}</time>
{@html convert.toHtml(line.logline)}
</p>
{/each}
{#if logError}
<p>{logError}</p>
{/if}
</code>
<style scoped>
code.logs {
overflow-y: scroll;
height: 100%;
font-size: 0.9rem;
}
p {
padding: 4px 8px;
margin: 0;
background-color: var(--neutral-raised);
word-break: break-all;
&:nth-child(odd) {
background-color: var(--table-bg);
}
}
time {
padding-right: 1ch;
color: var(--parpol-text);
}
</style>

View File

@ -0,0 +1,37 @@
import { SERVERMAN_BASE_URL } from '$env/static/private';
import EventSource from 'eventsource';
// Proxy the streaming logs from serverman
export async function GET({ params }) {
let logSource: EventSource;
let closed = false;
const stream = new ReadableStream({
start(controller) {
logSource = new EventSource(`${SERVERMAN_BASE_URL}/containers/${params.name}/logs`);
logSource.addEventListener('message', (ev) => {
if (closed) {
logSource.close();
return;
}
controller.enqueue(`data: ${ev.data}\n\n`);
});
logSource.addEventListener('error', (ev) => {
closed = true;
controller.close();
});
},
cancel() {
logSource.close();
}
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache'
}
});
}