Compare commits

..
11 Commits
Author SHA1 Message Date
amania-jailbreak cfe5bfb788 feat: 設定画面を実装
Workspace・認証モード・保存容量・配布上限・API情報を一覧表示する
設定画面を追加した。OIDCモードではログインユーザー表示とログアウト
ボタンを表示し、demoモードではアカウント情報を固定表示する。
- settings用のグリッド/行/バッジ/クォータスタイルをglobals.cssに追加
- AdminShellの未使用変数警告を解消
2026-08-05 16:27:29 +09:00
amania-jailbreak 55f172de3b fix: dashboard-view.tsx で lucide-react の Boxes import が欠落していたのを修正
Sourceが空のWorkspaceで Overview を開くと ReferenceError: Boxes is not
defined が発生して画面がクラッシュするため、import に Boxes を追加した。
2026-08-05 16:21:46 +09:00
amania-jailbreak f9ce4b218e auth: SSOログイン後の画面遷移とユーザー表示を修正
- コールバック成功時のリダイレクト先をWEB_ORIGINルートから
  /dashboardに変更(ランディングで止まらないように)
- ルートページでログイン済みならダッシュボードへ自動遷移
- サイドバーのユーザーカードをOIDC identityの表示名・メールに変更し、
  未ログイン時のみSSOログインリンクを表示
- demoヘッダ(x-demo-user)はdemoモード時のみ送信
2026-08-05 16:07:10 +09:00
amania-jailbreak 03440d269d api: バージョンとデプロイコミットを返す /version エンドポイントを追加
- package.jsonのversionとビルド時注入のコミットSHA(APP_COMMIT)を返す
- CoolifyのSOURCE_COMMITをbuild argとして受けるためDockerfile/composeを更新
- authModeも含め、デプロイ状態の切り分けに使えるようにした
2026-08-05 14:52:06 +09:00
amania-jailbreak 0a0ac00813 auth: OIDC_REDIRECT_URIのデフォルトを本番URLに変更
Coolifyの環境変数が未設定でもSSOコールバックが
https://api.altdock.app.amania.jp/auth/callback で動くよう、
config.tsのデフォルト値とcomposeの環境変数デフォルトを本番URLに統一した。
2026-08-05 14:33:03 +09:00
amania-jailbreak 54cb335d66 api: フロントエンドのAPI URLを本番ドメインにハードコード
シングルユーザー運用のため env フォールバックを廃止し、
APIオリジンを https://api.altdock.app.amania.jp に固定した。
- dashboard.ts / PublicSourceView.tsx の localhost:4000 フォールバックを削除
- API側 config.ts の PUBLIC_BASE_URL/WEB_ORIGIN デフォルトも本番ドメインに
- compose/web の NEXT_PUBLIC_API_BASE_URL 環境変数を削除(コード固定のため)
2026-08-05 14:07:18 +09:00
amania-jailbreak 236a08c865 env: Coolify本番ドメインをデフォルトに設定
- API公開URLを https://api.altdock.app.amania.jp に
- WEB_ORIGIN(ダッシュボード)を https://altdock.app.amania.jp に
- 管理画面のNEXT_PUBLIC_API_BASE_URLもAPIドメインに追従
- ローカル開発時は .env で localhost に上書き可能
2026-08-05 13:50:36 +09:00
amania-jailbreak d4202de0e1 web: vite devサーバーのHost許可リストを設定
Coolifyの公開ドメイン(altdock.app.amania.jp)がvite 8のHostチェックで
403 Blocked requestになるため、VITE_ALLOWED_HOSTSで許可ホストを
環境変数から設定できるようにした。composeのデフォルトは
altdock.app.amania.jp、カンマ区切り複数対応、* で全許可。
2026-08-05 13:42:35 +09:00
amania-jailbreak 7de6c3075e web: 管理画面のポートを3001に変更
- composeのwebを --port 3001 で起動し、ホスト側も3001に公開
- APIのCORS許可オリジン(WEB_ORIGIN)とconfig.tsのデフォルトを3001に追従
2026-08-05 13:33:44 +09:00
amania-jailbreak 7432a9af05 api: S3ストリーミングアップロードにContentLengthを渡し500エラーを解消
AWS SDK v3のflexible-checksumsはストリームBodyにContentLengthが
未指定だと x-amz-decoded-content-length: undefined を送信して
ERR_HTTP_INVALID_HEADER_VALUE になる。expectedSizeを明示的に渡し、
writeStream側もsizeBytesを常に指定するよう修正した。
2026-08-05 13:16:43 +09:00
amania-jailbreak 3bb4d5aa65 docker: Coolify対応のためDockerfileビルドに移行しbind mount依存を排除
- ルートDockerfileで npm ci をビルド時に一元実行(node_modulesの競合破損を防止)
- web/api/workerをYAMLアンカーで共通ビルド構成に変更し、.:/workspace のbind mountを削除
- vinextは --hostname でバインド指定(--host は無視される)
- minio-initを削除(APIがbucketを自動作成)し、MinIOをexposeのみの内部公開に変更
- .dockerignore を追加し、.wranglerディレクトリをDockerfile内で事前作成
2026-08-05 13:16:39 +09:00
16 changed files with 284 additions and 85 deletions
+24
View File
@@ -0,0 +1,24 @@
# dependencies
node_modules
# build outputs / caches
.next
dist
coverage
.vinext
.wrangler
# local data / misc
.data
.DS_Store
# secrets & env files
.env
.env.*
# logs
npm-debug.log*
# git / tooling
.git
.vercel
+14 -4
View File
@@ -1,11 +1,21 @@
NODE_ENV=development NODE_ENV=development
PORT=4000 PORT=4000
PUBLIC_BASE_URL=http://localhost:4000 # Public URLs (production defaults for Coolify).
WEB_ORIGIN=http://localhost:3000 # Local development: use http://localhost:4000 / http://localhost:3001 /
# http://localhost:4000 instead.
PUBLIC_BASE_URL=https://api.altdock.app.amania.jp
WEB_ORIGIN=https://altdock.app.amania.jp
NEXT_PUBLIC_AUTH_MODE=demo NEXT_PUBLIC_AUTH_MODE=demo
# Vite dev server Host allow-list (comma-separated, or * for any host).
# On Coolify set this to your public domain, e.g. altdock.app.amania.jp
VITE_ALLOWED_HOSTS=altdock.app.amania.jp
# --- Storage (MinIO via docker compose) ------------------------------------- # --- Storage (MinIO via docker compose) -------------------------------------
# Leave DATABASE_URL empty to run with an in-memory store instead of Postgres. # Leave DATABASE_URL empty to run with an in-memory store instead of Postgres.
# docker-compose.yml keeps MinIO internal-only (no published ports/domain) and
# all uploads flow through the API, so the API uses the internal endpoint.
# When running the API on the host instead of inside compose, re-add MinIO's
# host ports (9000/9001) to docker-compose.yml first.
DATABASE_URL=postgres://altdock:altdock@localhost:5432/altdock DATABASE_URL=postgres://altdock:altdock@localhost:5432/altdock
STORAGE_MODE=s3 STORAGE_MODE=s3
S3_ENDPOINT=http://localhost:9000 S3_ENDPOINT=http://localhost:9000
@@ -17,7 +27,7 @@ S3_FORCE_PATH_STYLE=true
# MinIO root credentials, consumed by docker-compose.yml. CHANGE in production. # MinIO root credentials, consumed by docker-compose.yml. CHANGE in production.
MINIO_ROOT_USER=minioadmin MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=minioadmin123 MINIO_ROOT_PASSWORD=minioadmin123
# Only needed when MinIO is behind a reverse proxy / domain (e.g. on Coolify): # Only needed when MinIO is exposed behind a reverse proxy / domain:
MINIO_SERVER_URL= MINIO_SERVER_URL=
MINIO_BROWSER_REDIRECT_URL= MINIO_BROWSER_REDIRECT_URL=
@@ -27,7 +37,7 @@ SESSION_SECRET=replace-with-at-least-32-random-characters
OIDC_ISSUER_URL= OIDC_ISSUER_URL=
OIDC_CLIENT_ID= OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET= OIDC_CLIENT_SECRET=
OIDC_REDIRECT_URI=http://localhost:4000/auth/callback OIDC_REDIRECT_URI=https://api.altdock.app.amania.jp/auth/callback
MAX_UPLOAD_BYTES=5368709120 MAX_UPLOAD_BYTES=5368709120
MAX_ARCHIVE_ENTRIES=2048 MAX_ARCHIVE_ENTRIES=2048
+21
View File
@@ -0,0 +1,21 @@
FROM node:22-bookworm
WORKDIR /workspace
# Build-time commit SHA, injected by docker compose (SOURCE_COMMIT) or Coolify.
# Exposed to the API at /version for deploy diagnostics.
ARG APP_COMMIT=unknown
ENV APP_COMMIT=${APP_COMMIT}
# package-lock.json is committed, so npm ci installs the exact dependency tree.
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY . .
# vinext/wrangler writes logs and registry state under .wrangler; the directory
# itself is git/dockerignored, so create it explicitly.
RUN mkdir -p /workspace/.wrangler
CMD ["npm", "run", "dev"]
+83 -4
View File
@@ -1,7 +1,86 @@
import Link from "next/link"; "use client";
import { ArrowUpRight, KeyRound, ShieldCheck, SlidersHorizontal } from "lucide-react";
export default function SettingsPage() { import { useEffect, useState } from "react";
return <><div className="admin-page-heading"><div><div className="admin-eyebrow">WORKSPACE SETTINGS</div><h1></h1><p></p></div></div><section className="admin-section-card settings-placeholder"><div className="settings-placeholder-icon"><SlidersHorizontal size={20} /></div><h2></h2><p>SSO</p><div className="settings-placeholder-grid"><div><ShieldCheck size={17} /><strong></strong><span>OIDC / demo mode</span></div><div><KeyRound size={17} /><strong></strong><span>Workspace単位の認可</span></div></div><Link className="admin-link" href="https://faq.altstore.io/developers/distribute-with-altstore-pal" target="_blank">PALドキュメントを開く <ArrowUpRight size={14} /></Link></section></>; import { BookOpen, Database, HardDrive, KeyRound, LogOut, Server, ShieldCheck, SlidersHorizontal, User, Workflow } from "lucide-react";
import { API_BASE, AUTH_MODE, formatBytes } from "@/app/lib/dashboard";
import { useDashboard } from "@/components/altdock/dashboard-provider";
import { Button } from "@/components/ui/button";
import type { Identity } from "@/packages/core/src/index";
function SettingRow({ icon: Icon, label, value, hint }: { icon: typeof User; label: string; value: React.ReactNode; hint?: string }) {
return <div className="settings-row"><span className="settings-row-icon"><Icon size={16} strokeWidth={1.8} /></span><div className="settings-row-copy"><span>{label}</span><strong>{value}</strong>{hint && <small>{hint}</small>}</div></div>;
} }
function Section({ title, description, children }: { title: string; description: string; children: React.ReactNode }) {
return <section className="admin-section-card settings-section"><div className="admin-section-card-header"><div><h2>{title}</h2><p>{description}</p></div></div>{children}</section>;
}
export default function SettingsPage() {
const { dashboard } = useDashboard();
const [identity, setIdentity] = useState<Identity | null>(null);
useEffect(() => {
if (AUTH_MODE !== "oidc") return;
let cancelled = false;
fetch(`${API_BASE}/api/v1/me`, { credentials: "include" })
.then((response) => response.ok ? response.json() : null)
.then((body) => { if (!cancelled && body?.identity) setIdentity(body.identity as Identity); })
.catch(() => {});
return () => { cancelled = true; };
}, []);
const storagePercent = dashboard ? Math.min(100, (dashboard.usage.storageBytes / Math.max(1, dashboard.limits.maxStorageBytes)) * 100) : 0;
return <>
<div className="admin-page-heading"><div><div className="admin-eyebrow">WORKSPACE SETTINGS</div><h1></h1><p>Workspace</p></div></div>
<div className="settings-grid">
<Section title="Workspace" description="AltDock上の配布単位となるWorkspace情報です。">
<div className="settings-rows">
<SettingRow icon={Workflow} label="Workspace名" value={dashboard?.workspace.name || "—"} />
<SettingRow icon={SlidersHorizontal} label="Workspace slug" value={<code>{dashboard?.workspace.slug || "—"}</code>} />
<SettingRow icon={User} label="ログインユーザー" value={AUTH_MODE === "oidc" ? (identity?.displayName || "—") : "Demo Developer"} hint={AUTH_MODE === "oidc" ? identity?.email : "demo@altdock.local"} />
</div>
</Section>
<Section title="認証" description="このAPIに接続している認証モードです。">
<div className="settings-rows">
<SettingRow icon={ShieldCheck} label="認証モード" value={AUTH_MODE === "oidc" ? <span className="settings-badge settings-badge-ok">OIDC SSO</span> : <span className="settings-badge">Demo mode</span>} hint={AUTH_MODE === "oidc" ? "汎用OIDCプロバイダでログインしています" : "開発用の簡易認証です。本番ではOIDCを推奨します"} />
{AUTH_MODE === "oidc" && <SettingRow icon={KeyRound} label="SSOセッション" value={identity ? <span className="settings-badge settings-badge-ok"></span> : <span className="settings-badge"></span>} />}
</div>
</Section>
<Section title="保存容量" description="Workspaceで使用しているストレージの割合です。">
<div className="settings-quota">
<div className="settings-quota-top"><span>{formatBytes(dashboard?.usage.storageBytes || 0)} / {formatBytes(dashboard?.limits.maxStorageBytes || 0)}</span><strong>{storagePercent.toFixed(1)}%</strong></div>
<div className="admin-progress"><span style={{ width: `${storagePercent}%` }} /></div>
<small>ADP ZIP本体と公開済みアセットの合計サイズ</small>
</div>
</Section>
<Section title="配布上限" description="環境変数で変更できる制限値です。">
<div className="settings-rows">
<SettingRow icon={HardDrive} label="ADP ZIP 1件の上限" value={formatBytes(dashboard?.limits.maxUploadBytes || 0)} />
<SettingRow icon={Database} label="WorkspaceあたりのSource数" value={dashboard?.limits.maxSources || "—"} />
<SettingRow icon={Workflow} label="Sourceあたりのアプリ数" value={dashboard?.limits.maxAppsPerSource || "—"} />
</div>
</Section>
<Section title="API" description="フロントエンドが利用しているAPIエンドポイントです。">
<div className="settings-rows">
<SettingRow icon={Server} label="API Base URL" value={<code>{API_BASE}</code>} />
<SettingRow icon={Server} label="ヘルスチェック" value={<a className="admin-link" href={`${API_BASE}/healthz`} target="_blank" rel="noreferrer">/healthz </a>} />
<SettingRow icon={Server} label="バージョン" value={<a className="admin-link" href={`${API_BASE}/version`} target="_blank" rel="noreferrer">/version </a>} />
</div>
</Section>
</div>
<section className="admin-section-card settings-footer">
<div><ShieldCheck size={17} /><span><strong>AltDock </strong><small>Source</small></span></div>
<div className="settings-footer-actions">
<Button variant="outline" size="sm" onClick={() => window.open("https://faq.altstore.io/developers/distribute-with-altstore-pal", "_blank")}><BookOpen size={14} />PALドキュメント</Button>
{AUTH_MODE === "oidc" && <Button variant="ghost" size="sm" onClick={() => { window.location.href = `${API_BASE}/auth/logout`; }}><LogOut size={14} /></Button>}
</div>
</section>
</>;
}
+3
View File
@@ -92,6 +92,9 @@ button { cursor: pointer; }
.admin-toast-viewport { position: fixed; z-index: 100; right: 18px; bottom: 18px; display: flex; width: 360px; max-width: calc(100vw - 36px); flex-direction: column; gap: 8px; outline: 0; }.admin-toast { position: relative; display: grid; grid-template-columns: 1fr auto; gap: 3px 12px; padding: 13px 14px; border: 1px solid var(--admin-line-strong); border-left: 3px solid var(--admin-green); border-radius: 7px; color: var(--admin-text); background: #fff; box-shadow: 0 10px 32px #17202c22; }.admin-toast.toast-error { border-left-color: var(--admin-red); }.admin-toast.toast-info { border-left-color: var(--admin-blue); }.admin-toast [data-radix-toast-title] { font-size: 11px; font-weight: 720; }.admin-toast [data-radix-toast-description] { color: var(--admin-muted); font-size: 10px; line-height: 1.45; }.admin-toast button { grid-column: 2; grid-row: 1 / span 2; align-self: center; border: 0; color: var(--admin-subtle); background: transparent; font-size: 16px; } .admin-toast-viewport { position: fixed; z-index: 100; right: 18px; bottom: 18px; display: flex; width: 360px; max-width: calc(100vw - 36px); flex-direction: column; gap: 8px; outline: 0; }.admin-toast { position: relative; display: grid; grid-template-columns: 1fr auto; gap: 3px 12px; padding: 13px 14px; border: 1px solid var(--admin-line-strong); border-left: 3px solid var(--admin-green); border-radius: 7px; color: var(--admin-text); background: #fff; box-shadow: 0 10px 32px #17202c22; }.admin-toast.toast-error { border-left-color: var(--admin-red); }.admin-toast.toast-info { border-left-color: var(--admin-blue); }.admin-toast [data-radix-toast-title] { font-size: 11px; font-weight: 720; }.admin-toast [data-radix-toast-description] { color: var(--admin-muted); font-size: 10px; line-height: 1.45; }.admin-toast button { grid-column: 2; grid-row: 1 / span 2; align-self: center; border: 0; color: var(--admin-subtle); background: transparent; font-size: 16px; }
.mobile-nav-dialog { top: 0; left: 0; width: min(290px, calc(100vw - 44px)); height: 100vh; max-height: none; padding: 0; border: 0; border-radius: 0 9px 9px 0; transform: none; }.mobile-nav-dialog .admin-sidebar-inner { padding: 16px 12px; }.mobile-nav-close { position: absolute; z-index: 2; top: 16px; right: 13px; display: inline-flex; width: 28px; height: 28px; align-items: center; justify-content: center; border: 0; border-radius: 5px; color: var(--admin-subtle); background: #f1f3f5; } .mobile-nav-dialog { top: 0; left: 0; width: min(290px, calc(100vw - 44px)); height: 100vh; max-height: none; padding: 0; border: 0; border-radius: 0 9px 9px 0; transform: none; }.mobile-nav-dialog .admin-sidebar-inner { padding: 16px 12px; }.mobile-nav-close { position: absolute; z-index: 2; top: 16px; right: 13px; display: inline-flex; width: 28px; height: 28px; align-items: center; justify-content: center; border: 0; border-radius: 5px; color: var(--admin-subtle); background: #f1f3f5; }
.settings-placeholder { max-width: 620px; padding: 34px; text-align: center; }.settings-placeholder-icon { display: inline-flex; width: 42px; height: 42px; align-items: center; justify-content: center; border-radius: 8px; color: var(--admin-orange); background: #fff1ed; }.settings-placeholder h2 { margin: 14px 0 7px; color: var(--admin-text); font-size: 16px; }.settings-placeholder > p { max-width: 420px; margin: 0 auto 22px; color: var(--admin-muted); font-size: 11px; line-height: 1.6; }.settings-placeholder-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; margin-bottom: 20px; }.settings-placeholder-grid > div { display: flex; align-items: center; gap: 8px; padding: 12px; border: 1px solid var(--admin-line); border-radius: 6px; text-align: left; }.settings-placeholder-grid svg { color: var(--admin-blue); }.settings-placeholder-grid strong, .settings-placeholder-grid span { display: block; }.settings-placeholder-grid strong { color: var(--admin-text); font-size: 10px; }.settings-placeholder-grid span { margin-top: 3px; color: var(--admin-subtle); font-size: 9px; } .settings-placeholder { max-width: 620px; padding: 34px; text-align: center; }.settings-placeholder-icon { display: inline-flex; width: 42px; height: 42px; align-items: center; justify-content: center; border-radius: 8px; color: var(--admin-orange); background: #fff1ed; }.settings-placeholder h2 { margin: 14px 0 7px; color: var(--admin-text); font-size: 16px; }.settings-placeholder > p { max-width: 420px; margin: 0 auto 22px; color: var(--admin-muted); font-size: 11px; line-height: 1.6; }.settings-placeholder-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; margin-bottom: 20px; }.settings-placeholder-grid > div { display: flex; align-items: center; gap: 8px; padding: 12px; border: 1px solid var(--admin-line); border-radius: 6px; text-align: left; }.settings-placeholder-grid svg { color: var(--admin-blue); }.settings-placeholder-grid strong, .settings-placeholder-grid span { display: block; }.settings-placeholder-grid strong { color: var(--admin-text); font-size: 10px; }.settings-placeholder-grid span { margin-top: 3px; color: var(--admin-subtle); font-size: 9px; }
.settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 15px; }.settings-section { margin-bottom: 0; }.settings-section .admin-section-card-header { padding-bottom: 11px; }.settings-rows { display: flex; flex-direction: column; padding: 0 19px 17px; }.settings-row { display: flex; align-items: center; gap: 11px; min-height: 44px; padding: 9px 0; border-top: 1px solid var(--admin-line); }.settings-row:first-child { border-top: 0; }.settings-row-icon { display: inline-flex; width: 28px; height: 28px; flex: 0 0 28px; align-items: center; justify-content: center; border-radius: 6px; color: var(--admin-muted); background: #f2f4f6; }.settings-row-icon svg { color: var(--admin-muted); }.settings-row-copy { min-width: 0; flex: 1; }.settings-row-copy span { display: block; color: var(--admin-subtle); font-size: 9px; font-weight: 700; letter-spacing: .05em; }.settings-row-copy strong { display: block; overflow: hidden; margin-top: 2px; color: var(--admin-text); font-size: 11px; font-weight: 620; text-overflow: ellipsis; white-space: nowrap; }.settings-row-copy code { color: var(--admin-muted); font-family: var(--font-geist-mono), monospace; font-size: 10px; }.settings-row-copy small { display: block; margin-top: 2px; color: var(--admin-subtle); font-size: 9px; }.settings-badge { display: inline-flex; align-items: center; min-height: 19px; padding: 0 8px; border: 1px solid var(--admin-line-strong); border-radius: 99px; color: var(--admin-muted); background: #f6f7f9; font-size: 9px; font-weight: 680; }.settings-badge-ok { border-color: #cfe5da; color: var(--admin-green); background: #edf7f2; }.settings-quota { padding: 2px 19px 18px; }.settings-quota-top { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 9px; color: var(--admin-muted); font-size: 10px; }.settings-quota-top strong { color: var(--admin-text); font-family: var(--font-geist-mono), monospace; font-size: 13px; font-weight: 620; }.settings-quota small { display: block; margin-top: 8px; color: var(--admin-subtle); font-size: 9px; }.settings-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 15px 19px; }.settings-footer > div:first-child { display: flex; align-items: center; gap: 11px; }.settings-footer svg { color: var(--admin-orange); }.settings-footer strong, .settings-footer small { display: block; }.settings-footer strong { color: var(--admin-text); font-size: 11px; font-weight: 650; }.settings-footer small { margin-top: 2px; color: var(--admin-subtle); font-size: 9px; }.settings-footer-actions { display: flex; align-items: center; gap: 8px; }
@media (max-width: 880px) { .settings-grid { grid-template-columns: 1fr; } }
@media (max-width: 760px) { .settings-rows { padding-inline: 14px; }.settings-quota { padding-inline: 14px; }.settings-footer { align-items: start; flex-direction: column; } }
@media (max-width: 1050px) { .admin-sidebar { width: 210px; flex-basis: 210px; }.admin-sidebar.is-collapsed { width: 64px; flex-basis: 64px; }.admin-collapse-toggle { left: 192px; }.admin-sidebar.is-collapsed ~ .admin-collapse-toggle { left: 50px; }.admin-metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.admin-overview-grid { grid-template-columns: 1fr; }.admin-overview-grid .admin-section-card { margin-bottom: 0; }.admin-topbar { padding-inline: 24px; }.admin-content { padding-inline: 24px; } } @media (max-width: 1050px) { .admin-sidebar { width: 210px; flex-basis: 210px; }.admin-sidebar.is-collapsed { width: 64px; flex-basis: 64px; }.admin-collapse-toggle { left: 192px; }.admin-sidebar.is-collapsed ~ .admin-collapse-toggle { left: 50px; }.admin-metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.admin-overview-grid { grid-template-columns: 1fr; }.admin-overview-grid .admin-section-card { margin-bottom: 0; }.admin-topbar { padding-inline: 24px; }.admin-content { padding-inline: 24px; } }
@media (max-width: 760px) { .admin-sidebar, .admin-collapse-toggle { display: none; }.admin-topbar { height: 53px; padding: 0 15px; }.mobile-menu-button { display: inline-flex; margin-right: 5px; }.admin-topbar-actions { gap: 8px; }.admin-search-trigger { min-width: 31px; width: 31px; justify-content: center; padding: 0; }.admin-search-trigger span, .admin-search-trigger kbd, .admin-topbar-action span, .admin-auth-link { display: none; }.admin-topbar-action { width: 31px; height: 31px; justify-content: center; padding: 0; border: 1px solid var(--admin-line-strong); border-radius: 6px; }.admin-content { padding: 24px 15px 45px; }.admin-page-heading { align-items: start; flex-direction: column; gap: 15px; margin-bottom: 22px; }.admin-page-heading h1 { font-size: 24px; }.admin-page-actions { width: 100%; }.admin-page-actions .ui-button { flex: 1; }.admin-metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }.admin-metric-card { min-height: 102px; padding: 13px; }.admin-metric-card strong { margin-top: 13px; font-size: 21px; }.admin-section-card-header { padding-inline: 14px; }.health-list { grid-template-columns: repeat(2, 1fr); padding-inline: 14px; }.quota-summary { padding-inline: 14px; }.admin-table th, .admin-table td { padding-inline: 13px; }.admin-toolbar { flex-wrap: wrap; padding-inline: 13px; }.admin-search-field { min-width: 100%; max-width: none; }.admin-toolbar-note { margin-left: 0; }.admin-form-grid, .release-detail-grid, .json-summary-grid, .settings-placeholder-grid { grid-template-columns: 1fr; }.ui-dialog-content { width: min(100% - 22px, 520px); padding: 20px 17px; }.ui-dialog-content.wide-dialog { width: min(100% - 22px, 680px); }.admin-toast-viewport { right: 11px; bottom: 11px; max-width: calc(100vw - 22px); }.admin-toast { width: 100%; } } @media (max-width: 760px) { .admin-sidebar, .admin-collapse-toggle { display: none; }.admin-topbar { height: 53px; padding: 0 15px; }.mobile-menu-button { display: inline-flex; margin-right: 5px; }.admin-topbar-actions { gap: 8px; }.admin-search-trigger { min-width: 31px; width: 31px; justify-content: center; padding: 0; }.admin-search-trigger span, .admin-search-trigger kbd, .admin-topbar-action span, .admin-auth-link { display: none; }.admin-topbar-action { width: 31px; height: 31px; justify-content: center; padding: 0; border: 1px solid var(--admin-line-strong); border-radius: 6px; }.admin-content { padding: 24px 15px 45px; }.admin-page-heading { align-items: start; flex-direction: column; gap: 15px; margin-bottom: 22px; }.admin-page-heading h1 { font-size: 24px; }.admin-page-actions { width: 100%; }.admin-page-actions .ui-button { flex: 1; }.admin-metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }.admin-metric-card { min-height: 102px; padding: 13px; }.admin-metric-card strong { margin-top: 13px; font-size: 21px; }.admin-section-card-header { padding-inline: 14px; }.health-list { grid-template-columns: repeat(2, 1fr); padding-inline: 14px; }.quota-summary { padding-inline: 14px; }.admin-table th, .admin-table td { padding-inline: 13px; }.admin-toolbar { flex-wrap: wrap; padding-inline: 13px; }.admin-search-field { min-width: 100%; max-width: none; }.admin-toolbar-note { margin-left: 0; }.admin-form-grid, .release-detail-grid, .json-summary-grid, .settings-placeholder-grid { grid-template-columns: 1fr; }.ui-dialog-content { width: min(100% - 22px, 520px); padding: 20px 17px; }.ui-dialog-content.wide-dialog { width: min(100% - 22px, 680px); }.admin-toast-viewport { right: 11px; bottom: 11px; max-width: calc(100vw - 22px); }.admin-toast { width: 100%; } }
+6 -3
View File
@@ -31,7 +31,9 @@ export type Notice = {
export type Visibility = SourceRecord["visibility"]; export type Visibility = SourceRecord["visibility"];
export const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:4000"; // Single-user deployment: hard-code the public API origin. The API is served
// at api.altdock.app.amania.jp; there is no per-environment fallback anymore.
export const API_BASE = "https://api.altdock.app.amania.jp";
export const AUTH_MODE = process.env.NEXT_PUBLIC_AUTH_MODE || "demo"; export const AUTH_MODE = process.env.NEXT_PUBLIC_AUTH_MODE || "demo";
export const DEMO_HEADERS = { "x-demo-user": "demo@altdock.local" }; export const DEMO_HEADERS = { "x-demo-user": "demo@altdock.local" };
@@ -39,7 +41,9 @@ export async function api<T>(path: string, init: RequestInit = {}) {
const headers = new Headers(init.headers); const headers = new Headers(init.headers);
headers.set("accept", "application/json"); headers.set("accept", "application/json");
if (init.body) headers.set("content-type", "application/json"); if (init.body) headers.set("content-type", "application/json");
Object.entries(DEMO_HEADERS).forEach(([key, value]) => headers.set(key, value)); if (AUTH_MODE === "demo") {
Object.entries(DEMO_HEADERS).forEach(([key, value]) => headers.set(key, value));
}
const response = await fetch(`${API_BASE}${path}`, { ...init, headers, credentials: "include" }); const response = await fetch(`${API_BASE}${path}`, { ...init, headers, credentials: "include" });
const body = (await response.json().catch(() => ({}))) as T & { error?: string; detail?: string }; const body = (await response.json().catch(() => ({}))) as T & { error?: string; detail?: string };
if (!response.ok) throw new Error(body.detail || body.error || `Request failed (${response.status})`); if (!response.ok) throw new Error(body.detail || body.error || `Request failed (${response.status})`);
@@ -102,4 +106,3 @@ export function flattenApps(dashboard: DashboardData) {
export function flattenReleases(dashboard: DashboardData) { export function flattenReleases(dashboard: DashboardData) {
return dashboard.sources.flatMap((entry) => entry.releases.map((release) => ({ release, source: entry.source, app: entry.apps.find((app) => app.id === release.appId) }))); return dashboard.sources.flatMap((entry) => entry.releases.map((release) => ({ release, source: entry.source, app: entry.apps.find((app) => app.id === release.appId) })));
} }
+15 -1
View File
@@ -1,10 +1,24 @@
"use client"; "use client";
import { useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowRight, Boxes, FileArchive, Gauge, KeyRound, ShieldCheck, UploadCloud } from "lucide-react"; import { ArrowRight, Boxes, FileArchive, Gauge, KeyRound, ShieldCheck, UploadCloud } from "lucide-react";
import { AUTH_MODE, API_BASE } from "@/app/lib/dashboard"; import { AUTH_MODE, API_BASE } from "@/app/lib/dashboard";
export default function Home() { export default function Home() {
const router = useRouter();
// SSOモードでログイン済みなら、ランディングを挟まずダッシュボードへ向かう。
useEffect(() => {
if (AUTH_MODE !== "oidc") return;
let cancelled = false;
fetch(`${API_BASE}/api/v1/me`, { credentials: "include" })
.then((response) => { if (response.ok && !cancelled) router.replace("/dashboard"); })
.catch(() => {});
return () => { cancelled = true; };
}, [router]);
return <main className="landing"> return <main className="landing">
<header className="landing-header"> <header className="landing-header">
<Link className="landing-brand" href="/"><span className="admin-brand-mark">A</span><span>AltDock</span><span className="landing-beta">BETA</span></Link> <Link className="landing-brand" href="/"><span className="admin-brand-mark">A</span><span>AltDock</span><span className="landing-beta">BETA</span></Link>
@@ -24,7 +38,7 @@ export default function Home() {
<Link className="ui-button ui-button-primary" href="/dashboard"> <ArrowRight size={15} /></Link> <Link className="ui-button ui-button-primary" href="/dashboard"> <ArrowRight size={15} /></Link>
<a className="ui-button ui-button-outline" href="https://faq.altstore.io/developers/distribute-with-altstore-pal" target="_blank" rel="noreferrer"></a> <a className="ui-button ui-button-outline" href="https://faq.altstore.io/developers/distribute-with-altstore-pal" target="_blank" rel="noreferrer"></a>
</div> </div>
<div className="landing-hero-meta"><Gauge size={14} />Demo mode · API <code>{API_BASE}</code></div> {AUTH_MODE === "demo" && <div className="landing-hero-meta"><Gauge size={14} />Demo mode · API <code>{API_BASE}</code></div>}
</div> </div>
<aside className="landing-hero-card" aria-hidden="true"> <aside className="landing-hero-card" aria-hidden="true">
<div className="landing-flow-step"><span className="landing-flow-num">1</span><span><UploadCloud size={16} />ADP ZIPをアップロード</span></div> <div className="landing-flow-step"><span className="landing-flow-num">1</span><span><UploadCloud size={16} />ADP ZIPをアップロード</span></div>
+2 -1
View File
@@ -12,7 +12,8 @@ type SourceDocument = {
apps: Array<{ name: string; bundleIdentifier: string; developerName: string; subtitle?: string; localizedDescription: string; iconURL?: string; category: string; versions: Array<{ version: string; buildVersion: string; date: string; downloadURL: string; size: number }> }>; apps: Array<{ name: string; bundleIdentifier: string; developerName: string; subtitle?: string; localizedDescription: string; iconURL?: string; category: string; versions: Array<{ version: string; buildVersion: string; date: string; downloadURL: string; size: number }> }>;
}; };
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || "http://localhost:4000"; // Single-user deployment: hard-code the public API origin.
const API_BASE = "https://api.altdock.app.amania.jp";
function formatBytes(value: number) { function formatBytes(value: number) {
if (!value) return "0 B"; if (!value) return "0 B";
+2 -1
View File
@@ -116,7 +116,8 @@ export async function registerAuthRoutes(app: FastifyInstance, appConfig: AppCon
reply.clearCookie(stateCookie, { path: "/auth" }); reply.clearCookie(stateCookie, { path: "/auth" });
reply.clearCookie(verifierCookie, { path: "/auth" }); reply.clearCookie(verifierCookie, { path: "/auth" });
reply.clearCookie(nonceCookie, { path: "/auth" }); reply.clearCookie(nonceCookie, { path: "/auth" });
return reply.redirect(appConfig.WEB_ORIGIN); // SSO成功後はランディングではなく管理ダッシュボードへ直接向かう。
return reply.redirect(`${appConfig.WEB_ORIGIN.replace(/\/$/, "")}/dashboard`);
}); });
app.get("/auth/logout", async (_request, reply) => { app.get("/auth/logout", async (_request, reply) => {
+3 -3
View File
@@ -14,8 +14,8 @@ const schema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"), NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
HOST: z.string().default("0.0.0.0"), HOST: z.string().default("0.0.0.0"),
PORT: numberFromEnv.default(4000), PORT: numberFromEnv.default(4000),
PUBLIC_BASE_URL: z.string().url().default("http://localhost:4000"), PUBLIC_BASE_URL: z.string().url().default("https://api.altdock.app.amania.jp"),
WEB_ORIGIN: z.string().url().default("http://localhost:3000"), WEB_ORIGIN: z.string().url().default("https://altdock.app.amania.jp"),
DATABASE_URL: z.string().optional(), DATABASE_URL: z.string().optional(),
STORAGE_MODE: z.enum(["local", "s3"]).default("local"), STORAGE_MODE: z.enum(["local", "s3"]).default("local"),
LOCAL_STORAGE_DIR: z.string().default(".data/storage"), LOCAL_STORAGE_DIR: z.string().default(".data/storage"),
@@ -30,7 +30,7 @@ const schema = z.object({
OIDC_ISSUER_URL: z.string().url().optional(), OIDC_ISSUER_URL: z.string().url().optional(),
OIDC_CLIENT_ID: z.string().optional(), OIDC_CLIENT_ID: z.string().optional(),
OIDC_CLIENT_SECRET: z.string().optional(), OIDC_CLIENT_SECRET: z.string().optional(),
OIDC_REDIRECT_URI: z.string().url().optional(), OIDC_REDIRECT_URI: z.string().url().default("https://api.altdock.app.amania.jp/auth/callback"),
MAX_UPLOAD_BYTES: numberFromEnv.default(5 * 1024 * 1024 * 1024), MAX_UPLOAD_BYTES: numberFromEnv.default(5 * 1024 * 1024 * 1024),
MAX_ARCHIVE_ENTRIES: numberFromEnv.default(2048), MAX_ARCHIVE_ENTRIES: numberFromEnv.default(2048),
MAX_EXPANDED_BYTES: numberFromEnv.default(8 * 1024 * 1024 * 1024), MAX_EXPANDED_BYTES: numberFromEnv.default(8 * 1024 * 1024 * 1024),
+10 -2
View File
@@ -4,6 +4,7 @@ import Fastify, { type FastifyReply, type FastifyRequest } from "fastify";
import cookie from "@fastify/cookie"; import cookie from "@fastify/cookie";
import cors from "@fastify/cors"; import cors from "@fastify/cors";
import { z } from "zod"; import { z } from "zod";
import pkg from "../../../package.json" with { type: "json" };
import { buildSourceDocument } from "../../../packages/core/src/index"; import { buildSourceDocument } from "../../../packages/core/src/index";
import type { Identity } from "../../../packages/core/src/index"; import type { Identity } from "../../../packages/core/src/index";
import { loadConfig } from "./config"; import { loadConfig } from "./config";
@@ -78,13 +79,20 @@ export async function createServer() {
// them in memory, so a 5 GiB ADP doesn't exhaust the process. // them in memory, so a 5 GiB ADP doesn't exhaust the process.
app.addContentTypeParser(["application/zip", "application/octet-stream"], (_request, payload, done) => done(null, payload)); app.addContentTypeParser(["application/zip", "application/octet-stream"], (_request, payload, done) => done(null, payload));
await app.register(cookie); await app.register(cookie);
// The control plane (web :3000) and API (:4000) are cross-origin, so allow // The control plane (web :3001) and API (:4000) are cross-origin, so allow
// the write methods the dashboard uses; @fastify/cors otherwise defaults to // the write methods the dashboard uses; @fastify/cors otherwise defaults to
// GET/HEAD/POST and blocks PATCH/PUT in the browser. // GET/HEAD/POST and blocks PATCH/PUT in the browser.
await app.register(cors, { origin: true, credentials: true, methods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] }); await app.register(cors, { origin: true, credentials: true, methods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] });
await registerAuthRoutes(app, appConfig); await registerAuthRoutes(app, appConfig);
app.get("/healthz", async () => ({ ok: true, service: "altdock-api", storageMode: appConfig.STORAGE_MODE })); app.get("/healthz", async () => ({ ok: true, service: "altdock-api", storageMode: appConfig.STORAGE_MODE }));
app.get("/version", async () => ({
name: "altdock-api",
version: pkg.version,
commit: process.env.APP_COMMIT || process.env.SOURCE_COMMIT || "unknown",
authMode: appConfig.AUTH_MODE,
node: process.version,
}));
app.get("/api/v1/me", async (request, reply) => { app.get("/api/v1/me", async (request, reply) => {
const identity = await requireIdentity(request, reply, appConfig); const identity = await requireIdentity(request, reply, appConfig);
@@ -180,7 +188,7 @@ export async function createServer() {
const upload = await runtime.store.getUpload(uploadId); const upload = await runtime.store.getUpload(uploadId);
if (!upload || upload.objectKey !== key) return reply.code(404).send({ error: "UPLOAD_NOT_FOUND" }); if (!upload || upload.objectKey !== key) return reply.code(404).send({ error: "UPLOAD_NOT_FOUND" });
const body = request.body as NodeJS.ReadableStream; const body = request.body as NodeJS.ReadableStream;
const receivedSize = await runtime.storage.writeUploadFromStream(key, body); const receivedSize = await runtime.storage.writeUploadFromStream(key, body, upload.expectedSize);
if (receivedSize !== upload.expectedSize) return reply.code(400).send({ error: "UPLOAD_SIZE_MISMATCH", expectedSize: upload.expectedSize, receivedSize }); if (receivedSize !== upload.expectedSize) return reply.code(400).send({ error: "UPLOAD_SIZE_MISMATCH", expectedSize: upload.expectedSize, receivedSize });
await runtime.store.updateUpload(upload.id, { receivedSize, status: "uploaded" }); await runtime.store.updateUpload(upload.id, { receivedSize, status: "uploaded" });
return { ok: true, receivedSize }; return { ok: true, receivedSize };
+9 -5
View File
@@ -27,7 +27,7 @@ export type UploadPlan =
export interface StorageAdapter { export interface StorageAdapter {
createUploadPlan(key: string, contentType: string, sizeBytes: number): Promise<UploadPlan>; createUploadPlan(key: string, contentType: string, sizeBytes: number): Promise<UploadPlan>;
completeMultipartUpload(key: string, uploadId: string | undefined, parts: Array<{ partNumber: number; etag: string }> | undefined): Promise<void>; completeMultipartUpload(key: string, uploadId: string | undefined, parts: Array<{ partNumber: number; etag: string }> | undefined): Promise<void>;
writeUploadFromStream(key: string, stream: NodeJS.ReadableStream): Promise<number>; writeUploadFromStream(key: string, stream: NodeJS.ReadableStream, sizeBytes?: number): Promise<number>;
writeObject(key: string, body: Buffer, contentType: string): Promise<void>; writeObject(key: string, body: Buffer, contentType: string): Promise<void>;
writeStream(key: string, transform: NodeJS.ReadWriteStream, contentType: string, sizeBytes: number, source: NodeJS.ReadableStream): Promise<void>; writeStream(key: string, transform: NodeJS.ReadWriteStream, contentType: string, sizeBytes: number, source: NodeJS.ReadableStream): Promise<void>;
downloadToFile(key: string, filePath: string): Promise<void>; downloadToFile(key: string, filePath: string): Promise<void>;
@@ -66,7 +66,8 @@ export class LocalStorage implements StorageAdapter {
return; return;
} }
async writeUploadFromStream(key: string, stream: NodeJS.ReadableStream) { // eslint-disable-next-line @typescript-eslint/no-unused-vars -- sizeBytes is only needed by S3.
async writeUploadFromStream(key: string, stream: NodeJS.ReadableStream, sizeBytes?: number) {
const filePath = this.pathFor(key); const filePath = this.pathFor(key);
await mkdir(dirname(filePath), { recursive: true }); await mkdir(dirname(filePath), { recursive: true });
await pipeline(stream, createWriteStream(filePath, { flags: "w" })); await pipeline(stream, createWriteStream(filePath, { flags: "w" }));
@@ -158,8 +159,11 @@ export class S3Storage implements StorageAdapter {
await this.client.send(new CompleteMultipartUploadCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key, UploadId: uploadId, MultipartUpload: { Parts: parts.sort((a, b) => a.partNumber - b.partNumber).map((part) => ({ PartNumber: part.partNumber, ETag: part.etag })) } })); await this.client.send(new CompleteMultipartUploadCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key, UploadId: uploadId, MultipartUpload: { Parts: parts.sort((a, b) => a.partNumber - b.partNumber).map((part) => ({ PartNumber: part.partNumber, ETag: part.etag })) } }));
} }
async writeUploadFromStream(key: string, stream: NodeJS.ReadableStream) { async writeUploadFromStream(key: string, stream: NodeJS.ReadableStream, sizeBytes?: number) {
await this.client.send(new PutObjectCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key, Body: stream as any })); // ContentLength must be provided for streaming bodies, otherwise the SDK's
// flexible-checksums middleware sends `x-amz-decoded-content-length:
// undefined` and the upload fails with ERR_HTTP_INVALID_HEADER_VALUE.
await this.client.send(new PutObjectCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key, Body: stream as any, ContentLength: sizeBytes }));
const metadata = await this.headObject(key); const metadata = await this.headObject(key);
return metadata?.sizeBytes || 0; return metadata?.sizeBytes || 0;
} }
@@ -169,7 +173,7 @@ export class S3Storage implements StorageAdapter {
} }
async writeStream(key: string, transform: NodeJS.ReadWriteStream, contentType: string, sizeBytes: number, source: NodeJS.ReadableStream) { async writeStream(key: string, transform: NodeJS.ReadWriteStream, contentType: string, sizeBytes: number, source: NodeJS.ReadableStream) {
const upload = this.client.send(new PutObjectCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key, Body: transform as any, ContentType: contentType, ContentLength: sizeBytes || undefined })); const upload = this.client.send(new PutObjectCommand({ Bucket: this.appConfig.S3_BUCKET, Key: key, Body: transform as any, ContentType: contentType, ContentLength: sizeBytes }));
await pipeline(source, transform); await pipeline(source, transform);
await upload; await upload;
} }
+20 -3
View File
@@ -9,6 +9,7 @@ import Link from "next/link";
import { useEffect, useMemo, useState, type ComponentType, type PropsWithChildren } from "react"; import { useEffect, useMemo, useState, type ComponentType, type PropsWithChildren } from "react";
import { AppWindow, BookOpen, Boxes, ChevronDown, ExternalLink, FileArchive, Gauge, Menu, PanelLeftClose, PanelLeftOpen, Plus, Search, Settings2, UploadCloud, X } from "lucide-react"; import { AppWindow, BookOpen, Boxes, ChevronDown, ExternalLink, FileArchive, Gauge, Menu, PanelLeftClose, PanelLeftOpen, Plus, Search, Settings2, UploadCloud, X } from "lucide-react";
import { API_BASE, AUTH_MODE, formatBytes } from "@/app/lib/dashboard"; import { API_BASE, AUTH_MODE, formatBytes } from "@/app/lib/dashboard";
import type { Identity } from "@/packages/core/src/index";
import { useDashboard } from "./dashboard-provider"; import { useDashboard } from "./dashboard-provider";
type NavItem = { href: string; label: string; icon: ComponentType<{ size?: number; strokeWidth?: number }> }; type NavItem = { href: string; label: string; icon: ComponentType<{ size?: number; strokeWidth?: number }> };
@@ -34,6 +35,7 @@ function SideNavigation({ collapsed, onNavigate }: { collapsed?: boolean; onNavi
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const { dashboard } = useDashboard(); const { dashboard } = useDashboard();
const { identity } = useIdentity();
const renderItem = (item: NavItem) => { const renderItem = (item: NavItem) => {
const external = item.href.startsWith("http"); const external = item.href.startsWith("http");
@@ -55,10 +57,24 @@ function SideNavigation({ collapsed, onNavigate }: { collapsed?: boolean; onNavi
<nav className="admin-nav admin-nav-secondary" aria-label="その他のナビゲーション">{secondaryNav.map(renderItem)}</nav> <nav className="admin-nav admin-nav-secondary" aria-label="その他のナビゲーション">{secondaryNav.map(renderItem)}</nav>
<div className="admin-sidebar-spacer" /> <div className="admin-sidebar-spacer" />
{!collapsed && <div className="admin-storage-card"><div className="admin-storage-row"><span></span><strong>{formatBytes(dashboard?.usage.storageBytes || 0)} / {formatBytes(dashboard?.limits.maxStorageBytes || 0)}</strong></div><div className="admin-progress"><span style={{ width: `${storagePercent}%` }} /></div><small>Free workspace plan</small></div>} {!collapsed && <div className="admin-storage-card"><div className="admin-storage-row"><span></span><strong>{formatBytes(dashboard?.usage.storageBytes || 0)} / {formatBytes(dashboard?.limits.maxStorageBytes || 0)}</strong></div><div className="admin-progress"><span style={{ width: `${storagePercent}%` }} /></div><small>Free workspace plan</small></div>}
{!collapsed && <div className="admin-user-card"><span className="admin-user-avatar">D</span><span><strong>Demo Developer</strong><small>demo@altdock.local</small></span><span className="admin-user-menu">···</span></div>} {!collapsed && <div className="admin-user-card"><span className="admin-user-avatar">{(identity?.displayName || "D").slice(0, 1).toUpperCase()}</span><span><strong>{identity?.displayName || "Demo Developer"}</strong><small>{identity?.email || "demo@altdock.local"}</small></span><span className="admin-user-menu">···</span></div>}
</div>; </div>;
} }
function useIdentity() {
const [identity, setIdentity] = useState<Identity | null>(null);
useEffect(() => {
if (AUTH_MODE !== "oidc") return;
let cancelled = false;
fetch(`${API_BASE}/api/v1/me`, { credentials: "include" })
.then((response) => response.ok ? response.json() : null)
.then((body) => { if (!cancelled && body?.identity) setIdentity(body.identity as Identity); })
.catch(() => {});
return () => { cancelled = true; };
}, []);
return { identity };
}
function CommandPalette({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { function CommandPalette({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
const router = useRouter(); const router = useRouter();
const commands = useMemo(() => primaryNav.map((item) => ({ ...item, action: () => router.push(item.href) })), [router]); const commands = useMemo(() => primaryNav.map((item) => ({ ...item, action: () => router.push(item.href) })), [router]);
@@ -70,7 +86,8 @@ export function AdminShell({ children }: PropsWithChildren) {
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
const [commandOpen, setCommandOpen] = useState(false); const [commandOpen, setCommandOpen] = useState(false);
const pathname = usePathname(); const pathname = usePathname();
const { notice, dismissNotice, dashboard } = useDashboard(); const { notice, dismissNotice } = useDashboard();
const { identity } = useIdentity();
useEffect(() => { useEffect(() => {
const handleShortcut = (event: KeyboardEvent) => { const handleShortcut = (event: KeyboardEvent) => {
@@ -87,7 +104,7 @@ export function AdminShell({ children }: PropsWithChildren) {
<aside className={`admin-sidebar ${collapsed ? "is-collapsed" : ""}`}><SideNavigation collapsed={collapsed} /></aside> <aside className={`admin-sidebar ${collapsed ? "is-collapsed" : ""}`}><SideNavigation collapsed={collapsed} /></aside>
<Dialog open={mobileOpen} onOpenChange={setMobileOpen}><DialogContent className="mobile-nav-dialog"><DialogTitle className="sr-only"></DialogTitle><DialogDescription className="sr-only"></DialogDescription><button className="mobile-nav-close" onClick={() => setMobileOpen(false)} aria-label="ナビゲーションを閉じる"><X size={17} /></button><SideNavigation onNavigate={() => setMobileOpen(false)} /></DialogContent></Dialog> <Dialog open={mobileOpen} onOpenChange={setMobileOpen}><DialogContent className="mobile-nav-dialog"><DialogTitle className="sr-only"></DialogTitle><DialogDescription className="sr-only"></DialogDescription><button className="mobile-nav-close" onClick={() => setMobileOpen(false)} aria-label="ナビゲーションを閉じる"><X size={17} /></button><SideNavigation onNavigate={() => setMobileOpen(false)} /></DialogContent></Dialog>
<section className="admin-main-column"> <section className="admin-main-column">
<header className="admin-topbar"><div className="admin-topbar-left"><Button className="mobile-menu-button" variant="ghost" size="icon" onClick={() => setMobileOpen(true)} aria-label="ナビゲーションを開く"><Menu size={18} /></Button><div className="admin-breadcrumb"><span>Workspace</span><span>/</span><strong>{pageLabel}</strong></div></div><div className="admin-topbar-actions"><button className="admin-search-trigger" onClick={() => setCommandOpen(true)}><Search size={15} /><span></span><kbd> K</kbd></button><Link className="admin-topbar-action" href="/dashboard/releases?upload=1"><Plus size={15} /></Link>{AUTH_MODE === "oidc" ? <a className="admin-auth-link" href={`${API_BASE}/auth/login`}>SSOでログイン</a> : <a className="admin-auth-link" href={`${API_BASE}/healthz`} target="_blank" rel="noreferrer">API status</a>}</div></header> <header className="admin-topbar"><div className="admin-topbar-left"><Button className="mobile-menu-button" variant="ghost" size="icon" onClick={() => setMobileOpen(true)} aria-label="ナビゲーションを開く"><Menu size={18} /></Button><div className="admin-breadcrumb"><span>Workspace</span><span>/</span><strong>{pageLabel}</strong></div></div><div className="admin-topbar-actions"><button className="admin-search-trigger" onClick={() => setCommandOpen(true)}><Search size={15} /><span></span><kbd> K</kbd></button><Link className="admin-topbar-action" href="/dashboard/releases?upload=1"><Plus size={15} /></Link>{AUTH_MODE === "oidc" ? (!identity ? <a className="admin-auth-link" href={`${API_BASE}/auth/login`}>SSOでログイン</a> : null) : <a className="admin-auth-link" href={`${API_BASE}/healthz`} target="_blank" rel="noreferrer">API status</a>}</div></header>
<div className="admin-content">{children}</div> <div className="admin-content">{children}</div>
</section> </section>
<button className="admin-collapse-toggle" onClick={() => setCollapsed((value) => !value)} aria-label={collapsed ? "サイドバーを展開" : "サイドバーを折りたたむ"}>{collapsed ? <PanelLeftOpen size={16} /> : <PanelLeftClose size={16} />}</button> <button className="admin-collapse-toggle" onClick={() => setCollapsed((value) => !value)} aria-label={collapsed ? "サイドバーを展開" : "サイドバーを折りたたむ"}>{collapsed ? <PanelLeftOpen size={16} /> : <PanelLeftClose size={16} />}</button>
+1 -1
View File
@@ -2,7 +2,7 @@
import { useEffect, useState, type FormEvent, type ReactNode } from "react"; import { useEffect, useState, type FormEvent, type ReactNode } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { Activity, ArrowUpRight, ChevronRight, Clipboard, Code2, Copy, Database, ExternalLink, Filter, HardDrive, Pencil, Plus, RefreshCw, Search, ShieldCheck, UploadCloud, XCircle, type LucideIcon } from "lucide-react"; import { Activity, ArrowUpRight, Boxes, ChevronRight, Clipboard, Code2, Copy, Database, ExternalLink, Filter, HardDrive, Pencil, Plus, RefreshCw, Search, ShieldCheck, UploadCloud, XCircle, type LucideIcon } from "lucide-react";
import type { AppRecord, ReleaseRecord, SourceRecord } from "@/packages/core/src/index"; import type { AppRecord, ReleaseRecord, SourceRecord } from "@/packages/core/src/index";
import { API_BASE, flattenApps, flattenReleases, formatBytes, formatDate, formatDateTime, statusLabel, statusTone, visibilityLabel, visibilityTone, type DashboardData, type DashboardSource, type Visibility } from "@/app/lib/dashboard"; import { API_BASE, flattenApps, flattenReleases, formatBytes, formatDate, formatDateTime, statusLabel, statusTone, visibilityLabel, visibilityTone, type DashboardData, type DashboardSource, type Visibility } from "@/app/lib/dashboard";
import { useDashboard } from "./dashboard-provider"; import { useDashboard } from "./dashboard-provider";
+47 -54
View File
@@ -3,14 +3,27 @@
# Environment Variables / secrets panel. # Environment Variables / secrets panel.
# #
# Coolify notes: # Coolify notes:
# - All credentials are parameterized; set them in Coolify's env to avoid # - web/api/worker are all built from the same root Dockerfile and run from
# the insecure local defaults. # the image contents. There are no bind mounts, so nothing depends on
# - MinIO has a real healthcheck, so api/worker wait until it can serve. # Coolify mounting the git repository at /workspace (relative paths are
# - The API also creates the S3 bucket itself on startup (ensureBucket), # mounted as empty named volumes there).
# so the stack no longer depends on minio-init completing successfully. # - node_modules is baked into the image once by `npm ci` at build time, so
# - If you expose MinIO through a domain, set MINIO_SERVER_URL (public S3 API # the containers never race each other running npm install.
# origin) and MINIO_BROWSER_REDIRECT_URL (public console origin) so # - MinIO is fully private: uploads always flow through the API (browser ->
# presigned upload URLs and console redirects work from the browser. # API -> MinIO) and the API creates its bucket on startup, so no
# minio-init container and no published MinIO ports/domain are required.
# - postgres published port exists only for local development; on Coolify,
# public traffic goes through its reverse proxy and MinIO stays internal.
x-altdock-app: &altdock-app
build:
context: .
dockerfile: Dockerfile
args:
# Coolify provides SOURCE_COMMIT at build time; falls back to unknown.
APP_COMMIT: ${SOURCE_COMMIT:-unknown}
working_dir: /workspace
restart: unless-stopped
services: services:
postgres: postgres:
@@ -35,12 +48,11 @@ services:
environment: environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin123} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin123}
# Public origins, only needed when MinIO is behind a reverse proxy/domain. # Internal-only: the API reaches MinIO over the compose network and uploads
MINIO_SERVER_URL: ${MINIO_SERVER_URL:-} # are proxied through the API, so no host ports / public domain are needed.
MINIO_BROWSER_REDIRECT_URL: ${MINIO_BROWSER_REDIRECT_URL:-} expose:
ports: - "9000"
- "9000:9000" - "9001"
- "9001:9001"
volumes: volumes:
- minio-data:/data - minio-data:/data
healthcheck: healthcheck:
@@ -50,35 +62,16 @@ services:
retries: 12 retries: 12
start_period: 10s start_period: 10s
# Optional belt-and-suspenders: pre-creates the bucket. The API does the
# same on startup, so this can be removed without affecting functionality.
minio-init:
image: minio/mc:latest
depends_on:
minio:
condition: service_healthy
entrypoint: ["/bin/sh", "-c"]
command: >-
"until mc alias set local http://minio:9000 ${MINIO_ROOT_USER:-minioadmin} ${MINIO_ROOT_PASSWORD:-minioadmin123}; do sleep 1; done;
mc mb --ignore-existing local/${S3_BUCKET:-altdock}"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin123}
api: api:
image: node:22-bookworm <<: *altdock-app
working_dir: /workspace command: sh -c "npm run db:migrate && exec npm run dev:api"
command: sh -c "npm install --no-audit --no-fund && npm run dev:api"
environment: environment:
NODE_ENV: development
PORT: 4000 PORT: 4000
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-http://localhost:4000}
WEB_ORIGIN: ${WEB_ORIGIN:-http://localhost:3000}
DATABASE_URL: postgres://${POSTGRES_USER:-altdock}:${POSTGRES_PASSWORD:-altdock}@postgres:5432/${POSTGRES_DB:-altdock} DATABASE_URL: postgres://${POSTGRES_USER:-altdock}:${POSTGRES_PASSWORD:-altdock}@postgres:5432/${POSTGRES_DB:-altdock}
STORAGE_MODE: s3 STORAGE_MODE: s3
# S3_ENDPOINT is the internal address; presigned URLs resolve via the # Internal address only; uploads are proxied through the API, so MinIO
# public MINIO_SERVER_URL when set. # never needs a public URL.
S3_ENDPOINT: ${S3_ENDPOINT:-http://minio:9000} S3_ENDPOINT: http://minio:9000
S3_REGION: ${S3_REGION:-us-east-1} S3_REGION: ${S3_REGION:-us-east-1}
S3_BUCKET: ${S3_BUCKET:-altdock} S3_BUCKET: ${S3_BUCKET:-altdock}
S3_ACCESS_KEY_ID: ${MINIO_ROOT_USER:-minioadmin} S3_ACCESS_KEY_ID: ${MINIO_ROOT_USER:-minioadmin}
@@ -86,10 +79,13 @@ services:
S3_FORCE_PATH_STYLE: "true" S3_FORCE_PATH_STYLE: "true"
AUTH_MODE: ${AUTH_MODE:-demo} AUTH_MODE: ${AUTH_MODE:-demo}
PROCESS_INLINE: "false" PROCESS_INLINE: "false"
# Public origin of the API; used for source.json downloadURLs and
# upload plan URLs. Override for local development.
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-https://api.altdock.app.amania.jp}
WEB_ORIGIN: ${WEB_ORIGIN:-https://altdock.app.amania.jp}
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI:-https://api.altdock.app.amania.jp/auth/callback}
ports: ports:
- "4000:4000" - "4000:4000"
volumes:
- .:/workspace
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -97,14 +93,12 @@ services:
condition: service_healthy condition: service_healthy
worker: worker:
image: node:22-bookworm <<: *altdock-app
working_dir: /workspace command: npm run dev:worker
command: sh -c "npm install --no-audit --no-fund && npm run dev:worker"
environment: environment:
NODE_ENV: development
DATABASE_URL: postgres://${POSTGRES_USER:-altdock}:${POSTGRES_PASSWORD:-altdock}@postgres:5432/${POSTGRES_DB:-altdock} DATABASE_URL: postgres://${POSTGRES_USER:-altdock}:${POSTGRES_PASSWORD:-altdock}@postgres:5432/${POSTGRES_DB:-altdock}
STORAGE_MODE: s3 STORAGE_MODE: s3
S3_ENDPOINT: ${S3_ENDPOINT:-http://minio:9000} S3_ENDPOINT: http://minio:9000
S3_REGION: ${S3_REGION:-us-east-1} S3_REGION: ${S3_REGION:-us-east-1}
S3_BUCKET: ${S3_BUCKET:-altdock} S3_BUCKET: ${S3_BUCKET:-altdock}
S3_ACCESS_KEY_ID: ${MINIO_ROOT_USER:-minioadmin} S3_ACCESS_KEY_ID: ${MINIO_ROOT_USER:-minioadmin}
@@ -112,8 +106,6 @@ services:
S3_FORCE_PATH_STYLE: "true" S3_FORCE_PATH_STYLE: "true"
AUTH_MODE: ${AUTH_MODE:-demo} AUTH_MODE: ${AUTH_MODE:-demo}
PROCESS_INLINE: "false" PROCESS_INLINE: "false"
volumes:
- .:/workspace
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -121,15 +113,16 @@ services:
condition: service_healthy condition: service_healthy
web: web:
image: node:22-bookworm <<: *altdock-app
working_dir: /workspace # vinext uses --hostname/--port (not vite's --host) to bind the dev server.
command: sh -c "npm install --no-audit --no-fund && npm run dev -- --host 0.0.0.0" command: npm run dev -- --hostname 0.0.0.0 --port 3001
environment: environment:
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL:-http://localhost:4000} # API origin is hard-coded in app/lib/dashboard.ts (single-user deploy).
NEXT_PUBLIC_AUTH_MODE: ${AUTH_MODE:-demo}
# Hosts allowed to reach the dev server (comma-separated, or * for any).
VITE_ALLOWED_HOSTS: ${VITE_ALLOWED_HOSTS:-altdock.app.amania.jp}
ports: ports:
- "3000:3000" - "3001:3001"
volumes:
- .:/workspace
depends_on: depends_on:
api: api:
condition: service_started condition: service_started
+24 -3
View File
@@ -11,6 +11,19 @@ const { d1, r2 } = hostingConfig;
// macOS Seatbelt blocks FSEvents, so Codex previews need polling for HMR. // macOS Seatbelt blocks FSEvents, so Codex previews need polling for HMR.
const isCodexSeatbeltSandbox = process.env.CODEX_SANDBOX === "seatbelt"; const isCodexSeatbeltSandbox = process.env.CODEX_SANDBOX === "seatbelt";
// Vite 8 blocks unknown Host headers by default. In dev (incl. Coolify
// proxying a public domain to the dev server) the public hosts must be
// allow-listed. Accepts a comma-separated list, or "*"/"true" for any host.
function parseAllowedHosts(raw: string | undefined): boolean | string[] | undefined {
if (!raw) return undefined;
const value = raw.trim();
if (!value || value === "*" || value === "true") return true;
return value
.split(",")
.map((host) => host.trim())
.filter(Boolean);
}
const localBindingConfig = { const localBindingConfig = {
main: "./worker/index.ts", main: "./worker/index.ts",
compatibility_flags: ["nodejs_compat"], compatibility_flags: ["nodejs_compat"],
@@ -43,10 +56,18 @@ export default defineConfig(async () => {
// Wrangler snapshots its log path while the Cloudflare plugin is imported. // Wrangler snapshots its log path while the Cloudflare plugin is imported.
const { cloudflare } = await import("@cloudflare/vite-plugin"); const { cloudflare } = await import("@cloudflare/vite-plugin");
const allowedHosts = parseAllowedHosts(process.env.VITE_ALLOWED_HOSTS);
return { return {
server: isCodexSeatbeltSandbox server:
? { watch: { useFsEvents: false, usePolling: true } } isCodexSeatbeltSandbox || allowedHosts
: undefined, ? {
...(isCodexSeatbeltSandbox
? { watch: { useFsEvents: false, usePolling: true } }
: {}),
...(allowedHosts ? { allowedHosts } : {}),
}
: undefined,
plugins: [ plugins: [
vinext(), vinext(),
sites(), sites(),