Initial Mist scaffold
Successor to the Josh Steam prototypes. Single-VM Docker Compose stack with
the load-bearing core/ logic ported from JoshSteam CDN with bug fixes.
Contents:
- backend/ FastAPI + Celery (same image, two entrypoints)
core/ hdiff, librsync, chain_replay, manifest, compression,
discord, steam, unrealpak, paths
api/ auth, catalog, admin, builds (skeletons) + downloads (real)
worker/ Celery factory replacing the missing prototype Tasks/__init__.py
db/ SQLAlchemy models + Alembic initial migration
- admin-web/ SvelteKit + Tailwind skeleton
- client/ Tauri 2 + Svelte skeleton (Mist placeholder UI)
- mistpipe/ click-based admin CLI with subcommand stubs
- docs/ ARCHITECTURE, DECISIONS (9 ADRs), RUNBOOK
- docker-compose.yml + dev overlay + .github/workflows
Bugs fixed during port:
- Routes/download.py:2 stray backslash on import line
- Utils/celery.py inspect.reserved() missing parens + double active() typo
- Hardcoded OneDrive/Desktop paths replaced with pydantic-settings config
- Discord webhook URL + RabbitMQ password moved to env vars
- Missing Tasks/__init__.py reconstructed as worker/__init__.py
Out of scope for this commit: route bodies, UI screens, mistpipe subcommand
bodies, real image builds.
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Mist Admin</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,49 @@
|
||||
// Typed fetch client skeleton for Mist API.
|
||||
// TODO: replace `any` returns with generated types once OpenAPI is wired.
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE ?? '';
|
||||
|
||||
function token(): string | null {
|
||||
if (typeof localStorage === 'undefined') return null;
|
||||
return localStorage.getItem('mist_token');
|
||||
}
|
||||
|
||||
async function call<T = unknown>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
const t = token();
|
||||
if (t) headers['Authorization'] = `Bearer ${t}`;
|
||||
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined
|
||||
});
|
||||
if (!res.ok) throw new Error(`${method} ${path} -> ${res.status}`);
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
login: (username: string, password: string) =>
|
||||
call<{ access_token: string }>('POST', '/auth/login', { username, password }),
|
||||
me: () => call<{ id: number; username: string; is_admin: boolean }>('GET', '/auth/me'),
|
||||
listGames: () => call<unknown[]>('GET', '/catalog/games'),
|
||||
createGame: (data: { title: string; app_id?: number; is_private?: boolean }) =>
|
||||
call('POST', '/admin/games', data),
|
||||
updateGame: (id: number, patch: Record<string, unknown>) =>
|
||||
call('PATCH', `/admin/games/${id}`, patch),
|
||||
deleteGame: (id: number) => call('DELETE', `/admin/games/${id}`),
|
||||
resyncSteam: (id: number) => call('POST', `/admin/games/${id}/resync-steam`),
|
||||
listUsers: () => call<unknown[]>('GET', '/admin/users'),
|
||||
createUser: (data: { username: string; password: string; is_admin?: boolean }) =>
|
||||
call('POST', '/admin/users', data),
|
||||
listBuildJobs: () => call<unknown[]>('GET', '/admin/build-jobs')
|
||||
};
|
||||
|
||||
export function setToken(t: string) {
|
||||
localStorage.setItem('mist_token', t);
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
localStorage.removeItem('mist_token');
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-neutral-950 text-neutral-100">
|
||||
<header class="border-b border-neutral-800 px-6 py-4 flex items-center justify-between">
|
||||
<div class="flex items-center gap-6">
|
||||
<a href="/" class="text-xl font-semibold tracking-wide">Mist Admin</a>
|
||||
<nav class="flex gap-4 text-sm text-neutral-400">
|
||||
<a href="/games" class="hover:text-neutral-100">Games</a>
|
||||
<a href="/users" class="hover:text-neutral-100">Users</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="text-xs text-neutral-500">v0.1.0 · skeleton</div>
|
||||
</header>
|
||||
<main class="p-6 max-w-5xl mx-auto">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
// Landing page placeholder. Eventually: build job activity feed + quick stats.
|
||||
</script>
|
||||
|
||||
<section class="space-y-4">
|
||||
<h1 class="text-3xl font-semibold">Welcome to Mist Admin</h1>
|
||||
<p class="text-neutral-400 max-w-2xl">
|
||||
This is the admin portal. From here you manage games, push updates, and provision friend accounts.
|
||||
Nothing is wired up yet — this is a skeleton.
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 pt-4">
|
||||
<a href="/games" class="block p-5 rounded-lg border border-neutral-800 hover:border-neutral-700 hover:bg-neutral-900 transition">
|
||||
<h2 class="font-semibold">Games</h2>
|
||||
<p class="text-sm text-neutral-500 mt-1">Catalog management, metadata, branches.</p>
|
||||
</a>
|
||||
<a href="/users" class="block p-5 rounded-lg border border-neutral-800 hover:border-neutral-700 hover:bg-neutral-900 transition">
|
||||
<h2 class="font-semibold">Users</h2>
|
||||
<p class="text-sm text-neutral-500 mt-1">Provision accounts for friends.</p>
|
||||
</a>
|
||||
<a href="/login" class="block p-5 rounded-lg border border-neutral-800 hover:border-neutral-700 hover:bg-neutral-900 transition">
|
||||
<h2 class="font-semibold">Login</h2>
|
||||
<p class="text-sm text-neutral-500 mt-1">Sign in with your admin account.</p>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { api, setToken } from '$lib/api';
|
||||
|
||||
let username = $state('');
|
||||
let password = $state('');
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
async function submit(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
error = null;
|
||||
try {
|
||||
const r = await api.login(username, password);
|
||||
setToken(r.access_token);
|
||||
window.location.href = '/games';
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="max-w-sm mx-auto space-y-4 pt-12">
|
||||
<h1 class="text-2xl font-semibold">Sign in</h1>
|
||||
<form onsubmit={submit} class="space-y-3">
|
||||
<label class="block">
|
||||
<span class="text-sm text-neutral-400">Username</span>
|
||||
<input bind:value={username} class="mt-1 w-full bg-neutral-900 border border-neutral-800 rounded px-3 py-2 outline-none focus:border-neutral-600" autocomplete="username" />
|
||||
</label>
|
||||
<label class="block">
|
||||
<span class="text-sm text-neutral-400">Password</span>
|
||||
<input type="password" bind:value={password} class="mt-1 w-full bg-neutral-900 border border-neutral-800 rounded px-3 py-2 outline-none focus:border-neutral-600" autocomplete="current-password" />
|
||||
</label>
|
||||
{#if error}
|
||||
<p class="text-sm text-red-400">{error}</p>
|
||||
{/if}
|
||||
<button type="submit" class="w-full bg-neutral-100 text-neutral-900 rounded py-2 font-medium hover:bg-white transition">Sign in</button>
|
||||
</form>
|
||||
</section>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api';
|
||||
|
||||
let users = $state<unknown[]>([]);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
users = await api.listUsers();
|
||||
} catch (e) {
|
||||
error = (e as Error).message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold">Users</h1>
|
||||
<button onclick={load} class="text-sm border border-neutral-800 rounded px-3 py-1.5 hover:border-neutral-600">Refresh</button>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="text-sm text-red-400">{error}</p>
|
||||
{/if}
|
||||
|
||||
{#if users.length === 0}
|
||||
<p class="text-sm text-neutral-500">No users provisioned yet. Use the "Create user" form (TODO) or `mist.scripts.create_user` from a container.</p>
|
||||
{:else}
|
||||
<ul class="divide-y divide-neutral-800">
|
||||
{#each users as u}
|
||||
<li class="py-3 text-sm">{JSON.stringify(u)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
Reference in New Issue
Block a user