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

View File

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

View File

@ -2,7 +2,7 @@
<nav aria-label="main navigation" class="navbar" role="navigation"> <nav aria-label="main navigation" class="navbar" role="navigation">
<div class="navbar-brand"> <div class="navbar-brand">
<router-link class="navbar-item" to="/"> <router-link class="navbar-item" to="/">
<Icon name="paper-plane">Pilotwings</Icon> <Icon name="send">Pilotwings</Icon>
</router-link> </router-link>
</div> </div>
<div class="navbar-menu"> <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 Container from '../views/Container.vue'
import Home from '../views/Home.vue' import Home from '../views/Home.vue'
import New from '../views/New.vue'
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHashHistory(import.meta.env.BASE_URL),
routes: [ routes: [
{ {
path: '/', path: '/',
component: Home, component: Home,
}, },
{
path: '/new',
component: New,
},
{ {
path: '/container/:name', path: '/container/:name',
component: Container, component: Container,

View File

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

View File

@ -1,9 +1,168 @@
<template> <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> </template>
<script lang="ts"> <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 { export default {
name: 'Container', 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> </script>

View File

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