Skip to content

Commit a919392

Browse files
committed
feat(ui): surface version and build hash
1 parent 53351a0 commit a919392

File tree

9 files changed

+284
-4
lines changed

9 files changed

+284
-4
lines changed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ exclude = ["examples", "tests", "plugins"]
1818
streamkit-core = { version = "0.1", path = "crates/core" }
1919
streamkit-nodes = { version = "0.1", path = "crates/nodes" }
2020
streamkit-engine = { version = "0.1", path = "crates/engine" }
21-
streamkit-server = { version = "0.1", path = "apps/skit" }
21+
streamkit-server = { version = "0.2", path = "apps/skit" }
2222
streamkit-client = { version = "0.1", path = "apps/skit-cli" }
2323
streamkit-api = { version = "0.1", path = "crates/api" }
2424
streamkit-plugin-wasm = { version = "0.1", path = "crates/plugin-wasm" }

apps/skit/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ name = "streamkit-server"
33
authors = ["Claudio Costa <cstcld91@gmail.com>", "StreamKit Contributors"]
44
repository = "https://github.com/streamer45/streamkit"
55
license = "MPL-2.0"
6-
version = "0.1.0"
6+
version = "0.2.0"
77
edition = "2021"
88
publish = false
99

apps/skit/build.rs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// SPDX-FileCopyrightText: © 2025 StreamKit Contributors
2+
//
3+
// SPDX-License-Identifier: MPL-2.0
4+
5+
// Build scripts communicate with Cargo via stdout (`cargo:` directives).
6+
// Allow `println!` here to emit those directives.
7+
#![allow(clippy::disallowed_macros)]
8+
9+
use std::{
10+
env, fs,
11+
path::{Path, PathBuf},
12+
process::Command,
13+
};
14+
15+
fn main() {
16+
println!("cargo:rerun-if-env-changed=SKIT_BUILD_HASH");
17+
println!("cargo:rerun-if-env-changed=GIT_SHA");
18+
println!("cargo:rerun-if-env-changed=GITHUB_SHA");
19+
20+
let git_head = find_git_head();
21+
if let Some(head_path) = git_head.as_ref() {
22+
println!("cargo:rerun-if-changed={}", head_path.display());
23+
24+
if let Ok(head_ref) = fs::read_to_string(head_path) {
25+
if let Some(reference) = head_ref.trim().strip_prefix("ref: ") {
26+
if let Some(repo_root) = head_path.parent().and_then(|dir| dir.parent()) {
27+
let ref_path = repo_root.join(reference);
28+
println!("cargo:rerun-if-changed={}", ref_path.display());
29+
}
30+
}
31+
}
32+
}
33+
34+
let hash = read_env_hash("SKIT_BUILD_HASH")
35+
.or_else(|| read_env_hash("GIT_SHA"))
36+
.or_else(|| read_env_hash("GITHUB_SHA"))
37+
.or_else(|| {
38+
git_hash(git_head.as_ref().and_then(|path| path.parent()).and_then(|p| p.parent()))
39+
})
40+
.unwrap_or_else(|| "unknown".to_string());
41+
42+
println!("cargo:rustc-env=SKIT_BUILD_HASH={hash}");
43+
}
44+
45+
fn read_env_hash(var: &str) -> Option<String> {
46+
let value = env::var(var).ok()?;
47+
let trimmed = value.trim();
48+
if trimmed.is_empty() {
49+
None
50+
} else {
51+
Some(trimmed.to_string())
52+
}
53+
}
54+
55+
fn git_hash(repo_root: Option<&Path>) -> Option<String> {
56+
let mut command = Command::new("git");
57+
command.args(["rev-parse", "HEAD"]);
58+
59+
if let Some(root) = repo_root {
60+
command.current_dir(root);
61+
}
62+
63+
let output = command.output().ok()?;
64+
65+
if !output.status.success() {
66+
return None;
67+
}
68+
69+
let hash = String::from_utf8(output.stdout).ok()?;
70+
let trimmed = hash.trim();
71+
if trimmed.is_empty() {
72+
None
73+
} else {
74+
Some(trimmed.to_string())
75+
}
76+
}
77+
78+
fn find_git_head() -> Option<PathBuf> {
79+
let manifest_dir = env::var("CARGO_MANIFEST_DIR").ok()?;
80+
let mut dir = PathBuf::from(manifest_dir);
81+
82+
loop {
83+
let head_path = dir.join(".git").join("HEAD");
84+
if head_path.exists() {
85+
return Some(head_path);
86+
}
87+
88+
if !dir.pop() {
89+
break;
90+
}
91+
}
92+
93+
None
94+
}

apps/skit/src/server.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,10 +85,15 @@ async fn profile_heap_handler(
8585
crate::profiling::profile_heap().await
8686
}
8787

88+
fn build_hash() -> &'static str {
89+
option_env!("SKIT_BUILD_HASH").unwrap_or("unknown")
90+
}
91+
8892
async fn health_handler() -> impl IntoResponse {
8993
Json(serde_json::json!({
9094
"status": "ok",
9195
"version": env!("CARGO_PKG_VERSION"),
96+
"build_hash": build_hash(),
9297
}))
9398
}
9499

docs/src/content/docs/guides/web-ui.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ skit auth print-admin-token
2525

2626
See the [Authentication guide](/guides/authentication/) for details.
2727

28+
## Finding Build Info
29+
30+
Click the StreamKit logo in the top-left corner to open the About modal. It shows the server
31+
version and build hash (commit) for debugging or support. The same data is available from the
32+
`/healthz` endpoint.
33+
2834
## Main Routes
2935

3036
The Web UI has four main routes:

e2e/tests/about.spec.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// SPDX-FileCopyrightText: © 2025 StreamKit Contributors
2+
//
3+
// SPDX-License-Identifier: MPL-2.0
4+
5+
import { test, expect } from '@playwright/test';
6+
7+
import { ensureLoggedIn } from './auth-helpers';
8+
9+
test.describe('About modal', () => {
10+
test('shows version and build hash when clicking the logo', async ({ page }) => {
11+
await page.goto('/design');
12+
await ensureLoggedIn(page);
13+
14+
const healthResponse = await page.request.get('/healthz');
15+
expect(healthResponse.ok()).toBeTruthy();
16+
const health = (await healthResponse.json()) as {
17+
version?: string;
18+
build_hash?: string;
19+
buildHash?: string;
20+
};
21+
22+
await page.getByRole('button', { name: 'About StreamKit' }).click();
23+
24+
const dialog = page.getByRole('dialog', { name: 'About StreamKit' });
25+
await expect(dialog).toBeVisible();
26+
27+
const versionValue = health.version ?? 'unknown';
28+
const buildHashValue = health.build_hash ?? health.buildHash ?? 'unknown';
29+
30+
await expect(dialog.getByLabel('Version')).toHaveValue(versionValue);
31+
await expect(dialog.getByLabel('Build hash')).toHaveValue(buildHashValue);
32+
33+
await dialog.getByRole('button', { name: 'Close' }).click();
34+
await expect(dialog).toBeHidden();
35+
});
36+
});

ui/src/Layout.tsx

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,34 @@ import { useShallow } from 'zustand/shallow';
1111
import logo from './assets/logo.png';
1212
import { LayoutPresetButtons } from './components/LayoutPresetButtons';
1313
import { Button } from './components/ui/Button';
14+
import {
15+
Dialog,
16+
DialogBody,
17+
DialogClose,
18+
DialogContent,
19+
DialogDescription,
20+
DialogFooter,
21+
DialogHeader,
22+
DialogOverlay,
23+
DialogPortal,
24+
DialogTitle,
25+
DialogTrigger,
26+
FormGroup,
27+
Input,
28+
Label,
29+
} from './components/ui/Dialog';
1430
import { useTheme, type ColorMode } from './context/ThemeContext';
31+
import { fetchHealth } from './services/health';
1532
import { LAYOUT_PRESETS, useLayoutStore, type LayoutPreset } from './stores/layoutStore';
1633
import { usePermissionStore } from './stores/permissionStore';
34+
import { getLogger } from './utils/logger';
35+
36+
const logger = getLogger('Layout');
37+
38+
type BuildInfo = {
39+
version: string;
40+
buildHash: string;
41+
};
1742

1843
const LayoutContainer = styled.div`
1944
display: flex;
@@ -44,6 +69,21 @@ const LogoContainer = styled.div`
4469
user-select: none;
4570
`;
4671

72+
const LogoButton = styled.button`
73+
display: inline-flex;
74+
align-items: center;
75+
padding: 0;
76+
border: none;
77+
background: none;
78+
cursor: pointer;
79+
80+
&:focus-visible {
81+
outline: none;
82+
box-shadow: var(--sk-focus-ring);
83+
border-radius: 8px;
84+
}
85+
`;
86+
4787
const Logo = styled.img`
4888
height: 42px;
4989
width: auto;
@@ -190,6 +230,8 @@ const Main = styled.main`
190230
`;
191231

192232
const Layout: React.FC = () => {
233+
const [buildInfo, setBuildInfo] = React.useState<BuildInfo | null>(null);
234+
const closeButtonRef = React.useRef<HTMLButtonElement | null>(null);
193235
const { colorMode, setColorMode } = useTheme();
194236
const role = usePermissionStore((s) => s.role);
195237
const { currentPreset, setPreset } = useLayoutStore(
@@ -204,12 +246,75 @@ const Layout: React.FC = () => {
204246
'focus-canvas',
205247
'inspector-focus',
206248
];
249+
const version = buildInfo?.version ?? 'unknown';
250+
const buildHash = buildInfo?.buildHash ?? 'unknown';
251+
const handleDialogOpenAutoFocus = React.useCallback((event: Event) => {
252+
event.preventDefault();
253+
closeButtonRef.current?.focus();
254+
}, []);
255+
256+
React.useEffect(() => {
257+
let cancelled = false;
258+
const controller = new AbortController();
259+
260+
(async () => {
261+
try {
262+
const info = await fetchHealth(controller.signal);
263+
if (!cancelled) {
264+
setBuildInfo(info);
265+
}
266+
} catch (err) {
267+
const isAbortError = err instanceof Error && err.name === 'AbortError';
268+
const isAbortRelated = err instanceof DOMException && err.name === 'AbortError';
269+
if (!cancelled && !isAbortError && !isAbortRelated) {
270+
logger.debug('Failed to load build info', err);
271+
}
272+
}
273+
})();
274+
275+
return () => {
276+
cancelled = true;
277+
controller.abort();
278+
};
279+
}, []);
207280

208281
return (
209282
<LayoutContainer>
210283
<Nav>
211284
<LogoContainer>
212-
<Logo src={logo} alt="StreamKit" />
285+
<Dialog>
286+
<DialogTrigger asChild>
287+
<LogoButton type="button" aria-label="About StreamKit">
288+
<Logo src={logo} alt="StreamKit" />
289+
</LogoButton>
290+
</DialogTrigger>
291+
<DialogPortal>
292+
<DialogOverlay />
293+
<DialogContent onOpenAutoFocus={handleDialogOpenAutoFocus}>
294+
<DialogHeader>
295+
<DialogTitle>About StreamKit</DialogTitle>
296+
<DialogDescription>Build info for support and debugging.</DialogDescription>
297+
</DialogHeader>
298+
<DialogBody>
299+
<FormGroup spacing="compact">
300+
<Label htmlFor="about-version">Version</Label>
301+
<Input id="about-version" readOnly value={version} />
302+
</FormGroup>
303+
<FormGroup spacing="compact">
304+
<Label htmlFor="about-build-hash">Build hash</Label>
305+
<Input id="about-build-hash" readOnly value={buildHash} />
306+
</FormGroup>
307+
</DialogBody>
308+
<DialogFooter>
309+
<DialogClose asChild>
310+
<Button ref={closeButtonRef} variant="primary">
311+
Close
312+
</Button>
313+
</DialogClose>
314+
</DialogFooter>
315+
</DialogContent>
316+
</DialogPortal>
317+
</Dialog>
213318
</LogoContainer>
214319
<NavLinks>
215320
<StyledNavLink to="/design">Design</StyledNavLink>

ui/src/services/health.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// SPDX-FileCopyrightText: © 2025 StreamKit Contributors
2+
//
3+
// SPDX-License-Identifier: MPL-2.0
4+
5+
import { fetchApi } from './base';
6+
7+
interface HealthResponse {
8+
status?: string;
9+
version?: string;
10+
build_hash?: string;
11+
buildHash?: string;
12+
}
13+
14+
export interface HealthStatus {
15+
status: string;
16+
version: string;
17+
buildHash: string;
18+
}
19+
20+
export async function fetchHealth(signal?: AbortSignal): Promise<HealthStatus> {
21+
const response = await fetchApi('/health', { signal });
22+
23+
if (!response.ok) {
24+
throw new Error(`Failed to fetch health: ${response.statusText}`);
25+
}
26+
27+
const data = (await response.json()) as HealthResponse;
28+
29+
return {
30+
status: data.status ?? 'unknown',
31+
version: data.version ?? 'unknown',
32+
buildHash: data.build_hash ?? data.buildHash ?? 'unknown',
33+
};
34+
}

0 commit comments

Comments
 (0)