feat: all functionnalities on status page work

This commit is contained in:
Michel Roux 2024-11-04 16:55:23 +01:00
parent f6ba1ac476
commit 5693656cc2
8 changed files with 244 additions and 13 deletions

View File

@ -5,6 +5,7 @@ from docker import errors, from_env
from docker.models.containers import Container
from dotenv import load_dotenv
from fastapi import Depends, FastAPI, HTTPException, Request
from fastapi.responses import PlainTextResponse
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
@ -55,6 +56,14 @@ class SerializedContainer(BaseModel):
engine: str | None
owner: str | None
environment: list[str]
logs: str | None
def container_logs(container: Container, tail: int) -> str | None:
try:
return container.logs(tail=tail).decode()
except errors.APIError:
return None
def serialize_container(container: Container) -> SerializedContainer:
@ -68,6 +77,7 @@ def serialize_container(container: Container) -> SerializedContainer:
engine=container.labels.get("engine"),
owner=container.labels.get("owner"),
environment=container.attrs["Config"]["Env"],
logs=container_logs(container, 100),
)
@ -119,7 +129,7 @@ def get_container(
class ContainerRequest(BaseModel):
image: str
environment: dict[str, str]
environment: list[str]
@app.post("/api/container/{container_name}")
@ -133,11 +143,11 @@ def create_or_update_container(
if not networks:
client.networks.create("pilotwings")
client.images.pull(request_body.image)
try:
container = select_container(container_name, credentials)
container.stop()
container.remove(v=True, force=True)
except errors.APIError:
delete_container(container_name, credentials)
except HTTPException:
pass
return serialize_container(
@ -153,6 +163,52 @@ def create_or_update_container(
)
@app.post("/api/container/{container_name}/pull")
def pull_container(
container_name: str,
credentials: Annotated[HTTPBasicCredentials, Depends(security)],
) -> SerializedContainer:
container = select_container(container_name, credentials)
if not container.image:
raise HTTPException(status_code=status.HTTP_410_GONE)
request_body = ContainerRequest(
image=container.image.tags[0], environment=container.attrs["Config"]["Env"]
)
return create_or_update_container(container_name, request_body, credentials)
@app.post("/api/container/{container_name}/restart")
def restart_container(
container_name: str,
credentials: Annotated[HTTPBasicCredentials, Depends(security)],
) -> SerializedContainer:
container = select_container(container_name, credentials)
container.restart()
return serialize_container(container)
@app.get("/api/container/{container_name}/logs", response_class=PlainTextResponse)
def get_container_logs(
container_name: str,
credentials: Annotated[HTTPBasicCredentials, Depends(security)],
) -> str:
container = select_container(container_name, credentials)
return container.logs().decode()
@app.delete("/api/container/{container_name}")
def delete_container(
container_name: str,
credentials: Annotated[HTTPBasicCredentials, Depends(security)],
) -> None:
container = select_container(container_name, credentials)
container.stop()
container.remove(v=True, force=True)
class AuthStaticFiles(StaticFiles):
async def __call__(
self, scope: types.Scope, receive: types.Receive, send: types.Send

View File

@ -2,7 +2,9 @@
<Navbar />
<section class="section">
<Breadcrumb />
<div class="container">
<RouterView />
</div>
</section>
</template>

View File

@ -2,7 +2,7 @@
<nav aria-label="main navigation" class="navbar" role="navigation">
<div class="navbar-brand">
<router-link class="navbar-item" to="/">
<Icon name="paper-plane">Pilotwings</Icon>
<Icon name="send">Pilotwings</Icon>
</router-link>
</div>
<div class="navbar-menu">

View File

@ -1,14 +1,19 @@
import { createRouter, createWebHistory } from 'vue-router'
import { createRouter, createWebHashHistory } from 'vue-router'
import Container from '../views/Container.vue'
import Home from '../views/Home.vue'
import New from '../views/New.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
history: createWebHashHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
component: Home,
},
{
path: '/new',
component: New,
},
{
path: '/container/:name',
component: Container,

View File

@ -5,6 +5,8 @@ export interface Container {
labels: Record<string, string>
status: string
health: string
owner: string
engine: string | null
owner: string | null
environment: string[]
logs: string | null
}

View File

@ -1,9 +1,168 @@
<template>
<oui>oui</oui>
<Loading class="content" :loading="loading" :message="error" type="danger">
<div class="block">
<h1>
<Icon name="cube">{{ container_name }}</Icon>
<router-link class="button ml-2" :to="`/update/${container_name}`">
<Icon name="edit">Update</Icon>
</router-link>
<button class="button ml-2" @click="refresh">
<Icon name="refresh">Refresh</Icon>
</button>
</h1>
</div>
<div class="box">
<div class="block is-flex is-align-items-baseline">
<Icon name="image">
<b>Image:</b>
<span class="m-2">{{ container?.image }}</span>
</Icon>
<button class="button ml-2" @click="pull">
<Icon name="cloud-download">Pull</Icon>
</button>
<button class="button ml-2" @click="remove">
<Icon name="trash">Remove</Icon>
</button>
</div>
<div class="block is-flex is-align-items-baseline">
<Icon name="support">
<b>Status:</b>
</Icon>
<Icon
:class="`has-text-${status_icon.type} m-2`"
:name="status_icon.name"
>
{{ container?.status }}
</Icon>
<button class="button ml-2" @click="restart">
<Icon name="refresh">Restart</Icon>
</button>
</div>
<div class="block is-flex is-align-items-baseline">
<Icon name="image"><b>Logs:</b></Icon>
<a
class="button ml-2"
:download="`${container_name}_logs.txt`"
:href="`/api/container/${container_name}/logs`"
>
<Icon name="download">Download</Icon>
</a>
</div>
<pre>{{ container?.logs?.trim().split('\n').reverse().join('\n') }}</pre>
</div>
</Loading>
</template>
<script lang="ts">
import axios, { AxiosError } from 'axios'
import type { Container } from '../types'
import Icon from '../components/Icon.vue'
import Loading from '../components/Loading.vue'
export default {
name: 'Container',
components: {
Icon,
Loading,
},
data() {
return {
container: null as Container | null,
error: '',
loading: true,
}
},
computed: {
container_name(): string {
return this.$route.params.name as string
},
status_icon(): { name: string; type: string } {
if (!this.container) return { name: 'question-circle', type: 'warning' }
switch (this.container.status) {
case 'running':
return { name: 'play', type: 'success' }
case 'restarting':
return { name: 'refresh', type: 'danger' }
case 'paused':
return { name: 'pause', type: 'warning' }
case 'exited':
return { name: 'stop', type: 'danger' }
default:
return { name: 'question-circle', type: 'warning' }
}
},
},
async mounted() {
await this.refresh()
},
methods: {
async pull() {
try {
this.loading = true
const response = await axios.post<Container>(
`/api/container/${this.container_name}/pull`,
)
this.container = response.data
} catch (error) {
console.error(error)
this.error =
error instanceof AxiosError
? error.message
: 'Error pulling container'
} finally {
this.loading = false
}
},
async refresh() {
try {
this.loading = true
const response = await axios.get<Container>(
`/api/container/${this.container_name}`,
)
this.container = response.data
} catch (error) {
console.error(error)
this.error =
error instanceof AxiosError
? error.message
: 'Error loading container'
} finally {
this.loading = false
}
},
async remove() {
if (!confirm('Are you sure you want to remove this container?')) return
try {
this.loading = true
await axios.delete(`/api/container/${this.container_name}`)
this.$router.push('/')
} catch (error) {
console.error(error)
this.error =
error instanceof AxiosError
? error.message
: 'Error deleting container'
} finally {
this.loading = false
}
},
async restart() {
try {
this.loading = true
const response = await axios.post<Container>(
`/api/container/${this.container_name}/restart`,
)
this.container = response.data
} catch (error) {
console.error(error)
this.error =
error instanceof AxiosError
? error.message
: 'Error restarting container'
} finally {
this.loading = false
}
},
},
}
</script>

View File

@ -1,11 +1,11 @@
<template>
<Loading class="container" :loading="loading" :message="error" type="danger">
<Loading class="content" :loading="loading" :message="error" type="danger">
<div class="block">
<router-link class="button" to="/new">
<Icon name="plus">New container</Icon>
</router-link>
</div>
<div class="block">
<div v-if="containers.length" class="box">
<ul>
<li v-for="container in containers" :key="container.id">
<router-link :to="`/container/${container.name}`">

View File

@ -0,0 +1,7 @@
<template><oui>oui</oui></template>
<script lang="ts">
export default {
name: 'New',
}
</script>